Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ should select among several targets.

Credential-bearing model, image, video, and search requests do not automatically follow HTTP redirects, including same-origin redirects. Configure the final upstream API URL instead of a redirecting alias. A redirect does not cause the server to resend credentials or the request body to its destination. The response owner retains its existing error or relay behavior; native Responses and compact routes can return the original 3xx and `Location` to the client. Client redirect behavior is separate from this server transport policy.

## Empty search answers

After hosted search, a clean but empty forced-answer pass receives one additional answer
attempt with tools removed and existing results retained. This can incur another model
request. A second empty answer fails; malformed calls and provider refusal or truncation
outcomes are preserved without this retry.

## Endpoint overview

| Client surface | Endpoint | Successful non-stream result | Successful stream or socket result |
Expand Down
59 changes: 56 additions & 3 deletions src/web-search/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, Ocx
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata";
import type { AttemptRecoveryKind } from "../usage/log";
import { isTruncatedStopReason } from "../responses/truncated-stop-reason";
import { bridgeToResponsesSSE } from "../bridge";
import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
import { runAnthropicWebSearch } from "./anthropic-executor";
Expand Down Expand Up @@ -230,6 +231,24 @@ function forcedAnswerNudge(): OcxMessage {
};
}

/**
* Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty.
* The recovery also removes every tool, so the model has nothing to call and can only return text;
* this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge}
* it is iteration-local and never touches the persisted `messages`.
*/
function forcedAnswerRetryNudge(): OcxMessage {
return {
role: "developer",
content:
"Your previous response contained no usable answer. Web search has finished for this turn and " +
"no tools are available for this response. Answer the user's question now in assistant text, " +
"using the web search results already gathered above. If those results are insufficient, say " +
"what is missing instead of returning an empty response.",
timestamp: Date.now(),
};
}

function jsonError(status: number, message: string): Response {
return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
status,
Expand Down Expand Up @@ -370,7 +389,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
const signal = internalAbort.signal;

// Hard iteration bound (termination safety net); forceAnswer normally ends the loop sooner.
const HARD_CAP = maxSearches + 2;
// One iteration beyond the forced answer is reserved for its empty-answer recovery below.
const HARD_CAP = maxSearches + 3;
let emptyAnswerRetries = 0;
const connectTimeoutMs = deps.connectTimeoutMs ?? 200_000;
const routedModelStallTimeoutMs = deps.routedModelStallTimeoutMs ?? 200_000;

Expand Down Expand Up @@ -407,12 +428,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// ignores what the search found, which reads to the user as "the search did nothing". Nudge it
// (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
// Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
const iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
? [...messages, forcedAnswerNudge()]
: messages;
// #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the
// model nothing to call, and the extra developer turn asks it for the text it just failed to
// produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry
// cannot repeat the same empty or tool-shaped response.
const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
const iterParsed: OcxParsedRequest = {
...parsed, stream: true,
context: { ...parsed.context, messages: iterMessages, tools: forceAnswer ? toolsNoWebSearch : allTools },
...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : forceAnswer ? toolsNoWebSearch : allTools },
};
// One cumulative header deadline spans every pool-key 429 rotation in this model iteration.
// clear() stops only its timer after final headers; the direct turn signal remains attached to
Expand Down Expand Up @@ -847,9 +875,34 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
// An unterminated call flushes AFTER the terminal event, so find
// the terminal rather than assuming it is last (#1001).
const terminalEvent = split.passthrough.find(event => event.type === "done");
if (terminalEvent?.type === "done" && !split.hasMalformedToolCall
&& isTruncatedStopReason(terminalEvent.stopReason)) {
// A provider refusal or truncation is authoritative, even without text.
// Preserve it once; neither an empty-answer retry nor a generic 502 applies.
yield* replay(split.passthrough.slice(split.streamedPassthroughCount));
return;
}
if (terminalEvent?.type === "done"
&& (split.hasMalformedToolCall
|| (!split.hasRealToolCall && !hasVisibleAssistantText(split.passthrough)))) {
// #1001 fixed the silent success by failing here. A malformed call still fails: it
// reports a protocol problem, and replaying it would only re-ask an unwell upstream.
// Silence is different — it is recoverable, so retry exactly once with the results
// already gathered before failing the turn.
console.warn("[web-search-loop] unusable forced answer", JSON.stringify({
model: parsed.modelId,
recoveryAttempt: emptyAnswerRetries,
searchCalls: split.calls.length,
malformed: split.hasMalformedToolCall,
stopReason: terminalEvent.stopReason,
eventTypes: [...new Set(split.passthrough.map(event => event.type))],
}));
if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
emptyAnswerRetries++;
console.warn("[web-search-loop] empty forced answer — retrying once without tools");
yield { type: "heartbeat" };
continue;
}
throw new LoopError(502, "forced-answer pass produced no usable assistant output");
}
}
Expand Down
4 changes: 4 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ Chat helper admission in `src/server/responses/core.ts` follows the
[deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper
claims stored main, after terminal vision, routed vision and search exclusions.

### Empty forced search answers

`src/web-search/loop.ts` makes at most one extra answer attempt after a clean forced-answer terminal with no visible output or tool call. The recovery has no tools and reuses gathered search results. Malformed calls fail before refusal/truncation passthrough, and well-formed recognized refusal/truncation terminals pass through unchanged, including empty or partial answers. The extra generation may incur provider usage.

## Scoped provider quota for Combo selection

`src/providers/quota.ts` publishes routing evidence only when a producer explicitly supplies its
Expand Down
157 changes: 157 additions & 0 deletions tests/web-search/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,163 @@ describe("issue #1001 — forced-answer passes must produce usable output", () =
expect(frames.some(frame => frame.event === "response.completed")).toBe(true);
expect(frames.some(frame => frame.event === "response.failed")).toBe(false);
});

// #1001 chose to fail rather than complete silently, which turned silence into a dead turn:
// the user sees "stream disconnected before completion: forced-answer pass produced no usable
// assistant output". Silence is recoverable, so the pass is retried once with no tools before
// the same error is reported. Malformed calls still fail immediately.
describe("empty forced answer recovery", () => {
function sequenceAdapter(passes: AdapterEvent[][], seen: OcxParsedRequest[]): ProviderAdapter {
let pass = 0;
return {
name: "sequence",
buildRequest: (request) => {
seen.push(request);
return { url: "https://routed.test/v1", method: "POST", headers: {}, body: "{}" };
},
fetchResponse: async () => new Response("wire", { status: 200 }),
async *parseStream() {
for (const event of passes[Math.min(pass++, passes.length - 1)] ?? []) yield event;
},
async parseResponse() {
throw new Error("parseResponse must be unreachable");
},
};
}

async function drivePasses(passes: AdapterEvent[][], seen: OcxParsedRequest[] = [], ordinaryTool = false, liveOutput = false) {
const response = await runWithWebSearch({
parsed: parseRequest({ model: "routed/model", input: "hi", stream: true, tools: [{ type: "web_search" }, ...(ordinaryTool ? [{ type: "function", name: "fixture", parameters: { type: "object", properties: {} } }] : [])] }),
adapter: sequenceAdapter(passes, seen),
forwardProvider,
hostedTool: { type: "web_search" },
selectedForwardHeaders: new Headers({ authorization: "Bearer token" }),
settings: { model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 30_000 },
maxSearches: 1,
streamRoutedModelOutput: liveOutput,
});
return collectSse(response.body!);
}

test("an empty forced pass is retried once and completes", async () => {
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
]);
expect(frames.some(frame => frame.event === "response.completed")).toBe(true);
expect(frames.some(frame => frame.event === "response.failed")).toBe(false);
});

test("the recovery pass asks for text with every tool removed", async () => {
const seen: OcxParsedRequest[] = [];
let sidecarCalls = 0;
const evidence = "Distinctive gathered result: fixture-42";
globalThis.fetch = (async (input, init) => {
sidecarCalls++;
expect(String(input)).toBe("https://chatgpt.test/v1/responses");
const body = JSON.parse(String(init?.body));
expect(body.input[0].content[0].text).toBe("recovery fixture query");
return new Response(
`event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: evidence })}\n\n`
+ 'event: response.completed\ndata: {"type":"response.completed"}\n\n',
{ headers: { "Content-Type": "text/event-stream" } },
);
}) as typeof fetch;
const actualSearch: AdapterEvent[] = webSearchFirstPass.map(event => event.type === "tool_call_delta"
? { ...event, arguments: JSON.stringify({ query: "recovery fixture query" }) } : event);
await drivePasses([
actualSearch,
[{ type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
], seen);
// The search pass plus the empty forced pass plus exactly one recovery — no extra upstream call.
expect(seen).toHaveLength(3);
const recovery = seen[2]!;
expect(recovery.options.toolChoice).toBe("none");
expect(recovery.context.tools).toEqual([]);
// The results gathered by the search reach the recovery turn as a tool result ...
const results = recovery.context.messages.filter(message => message.role === "toolResult");
expect(results).toHaveLength(1);
expect(JSON.stringify(results[0])).toContain(evidence);
expect(results).toEqual(seen[1]!.context.messages.filter(message => message.role === "toolResult"));
expect(sidecarCalls).toBe(1);
// ... and the recovery turn carries the developer nudge that asks for the missing text.
expect(recovery.context.messages.some(message =>
message.role === "developer" && String(message.content).includes("no tools are available")))
.toBe(true);
});

test("recovery removes ordinary tools as well as web search", async () => {
const seen: OcxParsedRequest[] = [];
await drivePasses([webSearchFirstPass, [{ type: "done" }], [{ type: "text_delta", text: "answer" }, { type: "done" }]], seen, true);
expect(seen).toHaveLength(3);
expect(seen[1]!.context.tools.length).toBeGreaterThan(0);
expect(seen[2]!.context.tools).toEqual([]);
expect(seen[2]!.options.toolChoice).toBe("none");
});

for (const liveOutput of [false, true]) {
for (const stopReason of ["max_tokens", "content_filter"]) {
test(`malformed calls fail before ${stopReason} passthrough, live=${liveOutput}`, async () => {
const seen: OcxParsedRequest[] = [];
const frames = await drivePasses([webSearchFirstPass, [
{ type: "tool_call_start", id: "partial", name: "fixture" },
{ type: "tool_call_delta", arguments: '{"partial":' },
{ type: "tool_call_start", id: "closed", name: "fixture" },
{ type: "tool_call_delta", arguments: "{}" },
{ type: "tool_call_end" },
{ type: "done", stopReason },
]], seen, true, liveOutput);
expect(seen).toHaveLength(2);
expect(frames.some(frame => frame.event === "response.failed")).toBe(true);
expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false);
expect(frames.some(frame => frame.event === "response.completed" || frame.event === "response.incomplete")).toBe(false);
});
}
}

for (const [stopReason, reason] of [["refusal", "content_filter"], ["content_filter", "content_filter"], ["max_tokens", "max_output_tokens"], ["length", "max_output_tokens"]]) {
for (const partial of [false, true]) {
test(`${stopReason} partial=${partial} stays authoritative without a retry`, async () => {
const seen: OcxParsedRequest[] = [];
const terminalPass: AdapterEvent[] = [
...(partial ? [{ type: "text_delta" as const, text: "partial answer" }] : []),
{ type: "done", stopReason },
];
const frames = await drivePasses([webSearchFirstPass, terminalPass, [{ type: "done" }]], seen, false, true);
expect(seen).toHaveLength(2);
expect(frames.filter(frame => ["response.incomplete", "response.completed", "response.failed"].includes(frame.event ?? "")).map(frame => frame.event)).toEqual(["response.incomplete"]);
const terminalResponse = frames.find(frame => frame.event === "response.incomplete")!.data.response as { incomplete_details: { reason: string } };
expect(terminalResponse.incomplete_details.reason).toBe(reason);
if (partial) expect(frames.filter(frame => frame.event === "response.output_text.delta").map(frame => frame.data.delta).join("")).toBe("partial answer");
});
}
}

test("a persistent empty forced pass still fails after the one recovery", async () => {
const seen: OcxParsedRequest[] = [];
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "done" }],
[{ type: "done" }],
], seen);
expect(seen).toHaveLength(3);
expect(frames.some(frame => frame.event === "response.failed")).toBe(true);
expect(frames.some(frame => frame.event === "response.completed")).toBe(false);
});

test("a malformed forced call is not retried", async () => {
const seen: OcxParsedRequest[] = [];
const frames = await drivePasses([
webSearchFirstPass,
[{ type: "tool_call_start", id: "", name: "" }, { type: "tool_call_end" }, { type: "done" }],
[{ type: "text_delta", text: "recovered answer" }, { type: "done" }],
], seen);
expect(seen).toHaveLength(2);
expect(frames.some(frame => frame.event === "response.failed")).toBe(true);
});
});
});

const routedProvider: OcxProviderConfig = {
Expand Down
Loading