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
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. |
| `modelMaxOutputTokens?` | `Record<string, number>` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. |
| `modelCosts?` | `Record<string, ProviderCostOverlay>` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → exact official correction → jawcode catalog → expected-price overlay → model-level vendor fallback, and an explicit all-zero user entry means a known-zero estimate; delete that model entry to restore automatic pricing. All-zero catalog metadata still falls through. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. |
| `headers?` | `Record<string, string>` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. An experimental relay may negotiate earlier completed-tool delivery with `x-opencodex-tool-completion: block-stop-v1`; the embedding shell must supply authenticated durable turn membership, and the relay must acknowledge the same response header and implement exact call-bound completion markers. Leave it unset for other providers. Early delivery applies only to eligible structured calls; later errors or cancellation cannot retract a call already delivered. |
| `headers?` | `Record<string, string>` | Extra upstream headers. For the classic OpenAI web-search sidecar, `x-openai-internal-codex-responses-lite` is removed case-insensitively after configured and selected headers are merged; other forwarded headers are preserved. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Requests that ride the provider outbound wrapper — model discovery, connection tests, and other proxy-originated diagnostics such as the Ollama show probe — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. An experimental relay may negotiate earlier completed-tool delivery with `x-opencodex-tool-completion: block-stop-v1`; the embedding shell must supply authenticated durable turn membership, and the relay must acknowledge the same response header and implement exact call-bound completion markers. Leave it unset for other providers. Early delivery applies only to eligible structured calls; later errors or cancellation cannot retract a call already delivered. |
| `forwardClientHeaders?` | `string[]` | `openai-responses` only: opt in to copying named inbound client metadata headers onto inference requests when the provider has not already set that header. Credential and transport-owned names such as `Authorization`, cookies, API-key headers, `Content-Type`, `Content-Length`, `Host`, and `x-oai-attestation` are rejected and also filtered at runtime. Header names are case-insensitive and may not repeat. `headers` remains authoritative; the existing caller `User-Agent` fallback still applies when neither surface sets one. Supported names: `originator`, `x-client-request-id`, `x-codex-app-version`, `user-agent`; all other names are refused. |
| `supportsNativeCompactionTrigger?` | `boolean` | `openai-responses` only: declare support for native v2 `compaction_trigger` input items. Defaults off for noncanonical gateways; preserves native compaction rather than requesting a portable text summary. Independent of the v1 endpoint and blob replay capabilities. |
| `supportsNativeCompactEndpoint?` | `boolean` | `openai-responses` only: declare support for native v1 `POST /responses/compact`. Defaults off for custom gateways. Key/local/forward routes use the provider's Responses URL plus `/compact` and its normal transport authentication; OAuth keeps routed compaction and its existing credential lifecycle. Does not enable v2 triggers or grant official OpenAI identity. |
Expand Down
8 changes: 5 additions & 3 deletions src/web-search/executor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { OcxProviderConfig } from "../types";
import { mergeCodexSelectedHeaders } from "../codex/forward-transport-headers";
import { CODEX_RESPONSES_LITE_HEADER, mergeCodexSelectedHeaders } from "../codex/forward-transport-headers";
import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
import { redactSecretString } from "../lib/redact";
import { sidecarEnter } from "../lib/sidecar-tracker";
Expand Down Expand Up @@ -84,9 +84,11 @@ export async function runWebSearch(
if (settings.reserveCompatibility && settings.model === NATIVE_RESERVE_MODEL) {
return { text: "", sources: [], error: "Luna Reserve compatibility is only available as a conversation model, not a search helper. Choose another search helper model." };
}
const headers = Object.fromEntries(mergeCodexSelectedHeaders(
const mergedHeaders = mergeCodexSelectedHeaders(
{ "Content-Type": "application/json", ...forwardProvider.headers }, selectedForwardHeaders,
));
);
mergedHeaders.delete(CODEX_RESPONSES_LITE_HEADER);
const headers = Object.fromEntries(mergedHeaders);
const body = {
model: settings.model,
instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION,
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ request-scoped OpenAI sidecar authority on completion, failure, and client cance
cancellation releases immediately rather than waiting on an abandoned upstream read — so a
recovery probe lease no search consumed is always returned.
`tests/web-search/web-search-bridge-replay.test.ts` pins the restore and each of those refusals.
A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence.
A forward OpenAI search sidecar retries a 429 only when the requested delay fits both its retry ceiling and the remaining overall sidecar deadline. A delay that cannot fit returns and records the original 429 so pool routing retains quota evidence. The classic OpenAI search helper in `src/web-search/executor.ts` removes the Responses Lite marker from merged configured and selected headers before sending its hosted-search request, while retaining other forwarded headers. `tests/web-search/web-search-sidecar-429.test.ts` covers both header sources.
Comment thread
lidge-jun marked this conversation as resolved.
Resolved
One search makes at most three physical sends in total: connection-reset recovery and 429 replays draw from the same budget, and a budget spent with a 429 in hand ends with that 429 as the recorded outcome.
OpenAI helpers recheck the resolved [stored-account credit policy](codex-account-controls.md#stored-account-authentication-policy) immediately before every physical send, including those retries. An unchanged caller-owned Direct credential is not governed by stored-pool consent.
An explicitly unfinished tool end is passed through and never executes. A leg whose
Expand Down
68 changes: 67 additions & 1 deletion tests/web-search/web-search-sidecar-429.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { runWebSearch as runOpenAiWebSearch } from "../../src/web-search/executor";
import { listOpenAiForwardSidecarCandidates } from "../../src/providers/openai-sidecar";
import type { OcxConfig } from "../../src/types";
import type { OcxConfig, OcxProviderConfig } from "../../src/types";

const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });
Expand Down Expand Up @@ -174,3 +174,69 @@ describe("web-search sidecar 429 replays", () => {
expect(recorded).toEqual(["connect_neutral"]);
});
});

test("issue #8160 - OpenAI search helper strips responses-lite marker from merged headers", async () => {
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | undefined;
globalThis.fetch = (async (_url: any, init: any) => {
capturedHeaders = new Headers(init?.headers);
return new Response(
'data: ' + JSON.stringify({ type: "response.completed", response: { status: "completed", output: [] } }) + '\n\n',
{ headers: { "content-type": "text/event-stream" } },
);
}) as typeof fetch;

try {
await runOpenAiWebSearch(
"synthetic query",
{ type: "web_search" },
{
adapter: "openai-responses",
authMode: "forward",
baseUrl: "https://chatgpt.com/backend-api/codex",
headers: { "x-openai-internal-codex-responses-lite": "true", "x-custom-test": "forwarded" },
} as any,
new Headers({
"x-openai-internal-codex-responses-lite": "true",
"chatgpt-account-id": "test-account",
}),
{ model: "gpt-6.1-sol", reasoning: "low", timeoutMs: 1_000 },
);

expect(capturedHeaders).toBeDefined();
expect(capturedHeaders!.has("x-openai-internal-codex-responses-lite")).toBe(false);
expect(capturedHeaders!.get("chatgpt-account-id")).toBe("test-account");
expect(capturedHeaders!.get("x-custom-test")).toBe("forwarded");
} finally {
globalThis.fetch = originalFetch;
}
});

test.each([
{ name: "configured header only", configured: true, selected: false },
{ name: "selected header only", configured: false, selected: true },
])("classic search strips the Responses Lite marker from $name", async ({ configured, selected }) => {
let sent: Headers | undefined;
globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
sent = new Headers(init?.headers);
return new Response('data: {"type":"response.completed","response":{"status":"completed","output":[]}}\n\n');
}) as typeof fetch;

await runOpenAiWebSearch(
"synthetic query",
{ type: "web_search" },
{
adapter: "openai-responses",
authMode: "forward",
baseUrl: "https://chatgpt.com/backend-api/codex",
headers: { ...(configured ? { "X-OpenAI-Internal-Codex-Responses-Lite": "true" } : {}), "x-custom-test": "forwarded" },
} as OcxProviderConfig,
new Headers({ ...(selected ? { "X-OpenAI-Internal-Codex-Responses-Lite": "true" } : {}), "chatgpt-account-id": "test-account" }),
{ model: "gpt-6.1-sol", reasoning: "low", timeoutMs: 1_000 },
);

expect(sent).toBeDefined();
expect(sent!.has("x-openai-internal-codex-responses-lite")).toBe(false);
expect(sent!.get("chatgpt-account-id")).toBe("test-account");
expect(sent!.get("x-custom-test")).toBe("forwarded");
});
Loading