From 6a133b30385b0efb92e403cdfbc22a003e207f2d Mon Sep 17 00:00:00 2001 From: Liam Hetherington Date: Thu, 12 Feb 2026 09:41:03 +0000 Subject: [PATCH] Fix Chutes single-step tool loop detection for OpenAI-compatible routes --- opencode/packages/opencode/src/session/llm.ts | 33 ++++- .../opencode/test/session/llm.test.ts | 124 ++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/opencode/packages/opencode/src/session/llm.ts b/opencode/packages/opencode/src/session/llm.ts index 69b4e3d..daf5e8c 100644 --- a/opencode/packages/opencode/src/session/llm.ts +++ b/opencode/packages/opencode/src/session/llm.ts @@ -73,6 +73,13 @@ export namespace LLM { retryState: RetryState, ): Promise { const { getLanguage, cfg, provider, auth, isCodex, system, params, options, headers, maxOutputTokens, tools, rateLimitConfig } = config + const activeTools = Object.keys(tools).filter((x) => x !== "invalid") + const singleStepTools = shouldLimitToolLoop({ + model: input.model, + provider, + options, + activeTools, + }) while (retryState.attempt <= (rateLimitConfig.maxRetries || 0)) { try { @@ -115,10 +122,10 @@ export namespace LLM { topP: params.topP, topK: params.topK, providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + activeTools, tools, // Chutes accounts each provider round-trip as a separate request, so keep SDK orchestration to one step. - stopWhen: input.model.providerID === "chutes" ? stepCountIs(1) : undefined, + stopWhen: singleStepTools ? stepCountIs(1) : undefined, maxOutputTokens, abortSignal: input.abort, headers: { @@ -467,4 +474,26 @@ export namespace LLM { } return false } + + function shouldLimitToolLoop(input: { + model: Provider.Model + provider: Provider.Info + options: Record + activeTools: string[] + }) { + if (input.activeTools.length === 0) return false + if (input.model.providerID === "chutes") return true + + const url = [ + input.model.api.url, + input.provider.options?.baseURL, + input.provider.options?.baseUrl, + input.options?.baseURL, + input.options?.baseUrl, + ] + .filter((x) => typeof x === "string") + .map((x) => x.toLowerCase()) + + return url.some((x) => x.includes("chutes.ai") || x.includes("/chutes/")) + } } diff --git a/opencode/packages/opencode/test/session/llm.test.ts b/opencode/packages/opencode/test/session/llm.test.ts index f74364a..8db7e1b 100644 --- a/opencode/packages/opencode/test/session/llm.test.ts +++ b/opencode/packages/opencode/test/session/llm.test.ts @@ -704,6 +704,130 @@ describe("session.llm.stream", () => { }) }) + test("limits tool runs to a single SDK step when OpenAI provider targets Chutes baseURL", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + const providerID = "openai" + const modelID = "gpt-4o-mini" + const fixture = await loadFixture(providerID, modelID) + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + createEventResponse( + [ + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { + name: "echo", + arguments: '{"value":"hello"}', + }, + }, + ], + }, + }, + ], + }, + { + id: "chatcmpl-1", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "tool_calls" }], + }, + ], + true, + ), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-openai-key", + baseURL: `${server.url.origin}/chutes/v1`, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(providerID, model.id) + const sessionID = "session-test-6" + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + temperature: 0.4, + } satisfies Agent.Info + + const user = { + id: "user-6", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID, modelID: resolved.id }, + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Use echo" }], + tools: { + echo: tool({ + inputSchema: jsonSchema({ + type: "object", + properties: { + value: { type: "string" }, + }, + required: ["value"], + additionalProperties: false, + }), + execute: async () => "ok", + }), + }, + }) + + for await (const _ of stream.fullStream) { + } + + const capture = await request + expect(capture.url.pathname.endsWith("/chat/completions")).toBe(true) + expect(state.queue.length).toBe(0) + }, + }) + }) + test("sends Google API payload for Gemini models", async () => { const server = state.server if (!server) {