Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/terminal-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/terminal-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "我接下来会修改。" },
Expand Down
Loading