Fix Chutes single-step tool loop detection for OpenAI-compatible routes
This commit is contained in:
@@ -73,6 +73,13 @@ export namespace LLM {
|
|||||||
retryState: RetryState,
|
retryState: RetryState,
|
||||||
): Promise<StreamOutput> {
|
): Promise<StreamOutput> {
|
||||||
const { getLanguage, cfg, provider, auth, isCodex, system, params, options, headers, maxOutputTokens, tools, rateLimitConfig } = config
|
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)) {
|
while (retryState.attempt <= (rateLimitConfig.maxRetries || 0)) {
|
||||||
try {
|
try {
|
||||||
@@ -115,10 +122,10 @@ export namespace LLM {
|
|||||||
topP: params.topP,
|
topP: params.topP,
|
||||||
topK: params.topK,
|
topK: params.topK,
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||||
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
activeTools,
|
||||||
tools,
|
tools,
|
||||||
// Chutes accounts each provider round-trip as a separate request, so keep SDK orchestration to one step.
|
// 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,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -467,4 +474,26 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldLimitToolLoop(input: {
|
||||||
|
model: Provider.Model
|
||||||
|
provider: Provider.Info
|
||||||
|
options: Record<string, any>
|
||||||
|
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/"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
test("sends Google API payload for Gemini models", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) {
|
if (!server) {
|
||||||
|
|||||||
Reference in New Issue
Block a user