diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 67b30c86e4..ce7dfcb33a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3578,9 +3578,13 @@ async function handleResponsesInner( cancelBodyOnAbort(upstreamResponse.body, upstream.signal); - // Anthropic-only: one bounded internal continuation re-ask for clean end_turn turns that - // announced an edit without emitting a tool call. - const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction; + // One bounded internal continuation re-ask for clean end_turn turns that announced an edit + // without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in + // per-provider via `terminalContinuationGuard` (the heuristic was tuned on Anthropic turns, + // so it stays off for the shared openai-chat adapter unless a provider enables it). + const terminalGuardEnabled = (activeAdapter.name === "anthropic" + || (activeAdapter.name === "openai-chat" && route.provider.terminalContinuationGuard === true)) + && !options.comboAttempt && !routedCompaction; /** * One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the * continuation on a 429 with the same-key retry budget (hoisted per request), then falls diff --git a/src/server/responses/terminal-guard.ts b/src/server/responses/terminal-guard.ts index aa2104b4e1..22865c7274 100644 --- a/src/server/responses/terminal-guard.ts +++ b/src/server/responses/terminal-guard.ts @@ -195,7 +195,7 @@ export async function* guardTerminalEventStream(options: GuardedEventStreamOptio for await (const event of source) { if (event.type === "done") { terminalSeen = true; - const analysis = options.adapterName === "anthropic" + const analysis = (options.adapterName === "anthropic" || options.adapterName === "openai-chat") ? analyzeTerminalTurn(parsed, seen) : { decision: "pass" as const }; const normalStop = event.stopReason !== "max_tokens" && event.stopReason !== "content_filter"; diff --git a/src/types.ts b/src/types.ts index d24811f430..15b7f4de18 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1450,6 +1450,18 @@ export interface OcxProviderConfig { * only on explicit `true`. See devlog/_plan/260709_parallel_tool_calls. */ parallelToolCalls?: boolean; + /** + * Opt-in: extend the no-tool-call terminal continuation guard to this provider's + * `openai-chat` routed turns. The guard (originally Anthropic-only, see + * devlog/_fin/260706_previous-response-id-400) issues one bounded internal re-ask when a + * model announces work but ends the turn without emitting a tool call. Self-hosted + * OpenAI-compatible gateways (GLM/Kimi-family, etc.) hit the same premature-completion + * pattern, but the heuristic that decides a "suspicious no-tool stop" was tuned on + * Anthropic turns, so it stays OFF by default for the many registry providers that share + * the `openai-chat` adapter. Enable only for a provider whose models are known to stop + * mid-work; non-`openai-chat` adapters ignore this flag. + */ + terminalContinuationGuard?: boolean; /** * Opt-in: forward `prompt_cache_key` to the upstream `/chat/completions` body. * OpenAI-specific extension; strict backends (Groq, Cerebras, etc.) reject unknown diff --git a/tests/terminal-guard-server.test.ts b/tests/terminal-guard-server.test.ts index ba596cb808..2681f7a6cf 100644 --- a/tests/terminal-guard-server.test.ts +++ b/tests/terminal-guard-server.test.ts @@ -37,6 +37,27 @@ const continuationTurn = [ 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ].join(""); +/** Build an OpenAI Chat Completions SSE response from raw frames. */ +function chatSse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +// A clean end-of-turn with only assistant text and no tool call — the suspicious +// no-tool completion the guard is meant to re-ask (mirrors firstTurn for openai-chat). +const chatFirstTurn = [ + 'data: {"choices":[{"delta":{"content":"我接下来会修改相关文件。"}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n', + "data: [DONE]\n\n", +].join(""); + +// The continuation turn emits the tool call the model should have produced. +const chatContinuationTurn = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"exec_command","arguments":""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n', + "data: [DONE]\n\n", +].join(""); + describe("server terminal guard integration", () => { let originalFetch: typeof fetch; let calls: number; @@ -317,4 +338,86 @@ describe("server terminal guard integration", () => { expect(text).not.toContain("upstream_stall_timeout"); }, 5_000); + test("openai-chat provider without terminalContinuationGuard does not re-ask", async () => { + const chatConfig = { + port: 0, + defaultProvider: "glm-gw", + providers: { + "glm-gw": { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "key", + defaultModel: "glm-5.2", + models: ["glm-5.2"], + }, + }, + } as unknown as OcxConfig; + let sends = 0; + globalThis.fetch = (async () => { + sends += 1; + return chatSse(chatFirstTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + // Guard is opt-in for openai-chat: no continuation, so exactly one upstream call. + expect(sends).toBe(1); + expect(text).toContain("response.completed"); + }); + + test("openai-chat provider with terminalContinuationGuard re-asks once and forwards the tool call", async () => { + const chatConfig = { + port: 0, + defaultProvider: "glm-gw", + providers: { + "glm-gw": { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "key", + defaultModel: "glm-5.2", + models: ["glm-5.2"], + terminalContinuationGuard: true, + }, + }, + } as unknown as OcxConfig; + let sends = 0; + const bodies: Record[] = []; + globalThis.fetch = (async (_input, init) => { + sends += 1; + bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return chatSse(sends === 1 ? chatFirstTurn : chatContinuationTurn); + }) as typeof fetch; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "glm-gw/glm-5.2", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), chatConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + // Opted-in openai-chat provider: one bounded continuation, so two upstream calls. + expect(sends).toBe(2); + expect(text).toContain("response.completed"); + expect(text).toContain("exec_command"); + const messages = bodies[1]?.messages as Array<{ role?: string; content?: unknown }>; + expect(messages.some(m => m.role === "developer" || m.role === "system")).toBe(true); + }); + }); diff --git a/tests/terminal-guard.test.ts b/tests/terminal-guard.test.ts index 4cda0b1f69..9fe5b68d4d 100644 --- a/tests/terminal-guard.test.ts +++ b/tests/terminal-guard.test.ts @@ -212,6 +212,54 @@ describe("terminal guard", () => { expect(actual.filter(event => event.type === "done")).toHaveLength(1); }); + test("guards an openai-chat stream (opted-in provider) with one continuation", async () => { + let continuations = 0; + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("请检查这个问题并修复代码"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + })(), + continuation: () => { + continuations += 1; + return (async function* () { + yield { type: "tool_call_start", id: "call_1", name: "exec_command" } as AdapterEvent; + yield { type: "tool_call_end" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 20, outputTokens: 3 } } as AdapterEvent; + })(); + }, + adapterName: "openai-chat", + })) actual.push(event); + + expect(continuations).toBe(1); + expect(actual.some(event => event.type === "assistant_boundary")).toBe(true); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + + test("does not guard adapters other than anthropic/openai-chat", async () => { + let continuations = 0; + const actual: AdapterEvent[] = []; + for await (const event of guardTerminalEventStream({ + parsed: parsed("请检查这个问题并修复代码"), + firstEvents: (async function* () { + yield { type: "text_delta", text: "我接下来会修改相关文件。" } as AdapterEvent; + yield { type: "done", usage: { inputTokens: 10, outputTokens: 2 } } as AdapterEvent; + })(), + continuation: () => { + continuations += 1; + return (async function* () { + yield { type: "done" } as AdapterEvent; + })(); + }, + adapterName: "openai-responses", + })) actual.push(event); + + expect(continuations).toBe(0); + expect(actual.some(event => event.type === "assistant_boundary")).toBe(false); + expect(actual.filter(event => event.type === "done")).toHaveLength(1); + }); + test("serializes the guarded boundary as separate assistant output items", () => { const response = buildResponseJSON([ { type: "text_delta", text: "我接下来会修改。" },