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.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: "我接下来会修改。" },