From aa04ee54798bbedff8bfdf7332046494af250542 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sat, 19 Sep 2026 23:32:53 +0800 Subject: [PATCH 1/2] fix(transport): fill a default User-Agent on proxy-originated provider outbound Proxy-originated requests through the provider outbound wrapper (model discovery, connection tests, quota probes) carried no User-Agent: there is no client request to inherit one from and the pinned Node-style transport sends none, so WAF-fronted gateways answered discovery with 403 and the provider looked like it had no models (#5104). The wrapper now fills User-Agent: opencodex when the caller names none; registry static headers, provider headers, and vendor client fingerprints keep their value and spelling. Inference traffic never uses this wrapper, so the client-fingerprint rationale of #1751 is unaffected. --- .../docs/reference/configuration/providers.md | 2 +- src/lib/provider-outbound.ts | 44 ++++++++- structure/transports/inventory.md | 6 ++ tests/providers/provider-outbound.test.ts | 96 +++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a8b6f0c7333..823d0a5536b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -171,7 +171,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` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | | `modelCosts?` | `Record` | 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` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | +| `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Proxy-originated requests — model discovery and connection tests — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | | `vercelGatewayRouting?` | `VercelGatewayRouting` | Default Vercel AI Gateway `order`, `only`, and `sort` (`"cost"` \| `"ttft"` \| `"tps"`) preferences; valid only for canonical Vercel AI Gateway with `openai-chat`. | diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 87bfa94584b..936902d3863 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -104,14 +104,56 @@ export async function providerRedirectError(response: Response, requestUrl: stri return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`; } +/** + * Default client identity for proxy-originated provider outbound requests. + * + * Every request through the provider outbound wrapper — connection tests, model + * discovery, quota probes — is initiated by the proxy itself, so there is no + * client request to inherit a User-Agent from, and the pinned Node-style + * transport sends none. WAF/CDN front ends commonly answer UA-less requests + * with a 403 that surfaced as "provider added but no models" (#5104). A caller + * that materializes its own User-Agent — registry static headers, provider + * `headers`, or a vendor-specific client fingerprint — keeps its value and + * spelling; this only fills the name nobody claimed. Inference traffic never + * uses this wrapper, so the client-fingerprint rationale of #1751 is + * unaffected. + */ +const PROVIDER_OUTBOUND_DEFAULT_USER_AGENT = "opencodex"; + +function hasUserAgentHeader(headers: HeadersInit | null | undefined): boolean { + if (!headers) return false; + if (headers instanceof Headers) return headers.has("user-agent"); + if (Array.isArray(headers)) return headers.some(([name]) => name.toLowerCase() === "user-agent"); + return Object.keys(headers).some(name => name.toLowerCase() === "user-agent"); +} + +function withDefaultOutboundUserAgent( + init: ProviderGetInit | ProviderPostInit, +): ProviderGetInit | ProviderPostInit { + const headers = init.headers; + if (hasUserAgentHeader(headers)) return init; + if (headers instanceof Headers) { + const merged = new Headers(headers); + merged.set("User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT); + return { ...init, headers: merged }; + } + if (Array.isArray(headers)) { + return { ...init, headers: [...headers, ["User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT]] }; + } + return { ...init, headers: { ...(headers ?? {}), "User-Agent": PROVIDER_OUTBOUND_DEFAULT_USER_AGENT } }; +} + async function providerOutboundRequest( name: string, provider: ProviderOutboundConfig, url: string, method: "GET" | "POST", - init: ProviderGetInit | ProviderPostInit, + rawInit: ProviderGetInit | ProviderPostInit, dependencies: ProviderOutboundDependencies = {}, ): Promise { + // See PROVIDER_OUTBOUND_DEFAULT_USER_AGENT: this wrapper only carries proxy-originated + // diagnostic traffic, so it identifies itself unless the caller already did. + const init = withDefaultOutboundUserAgent(rawInit); const postUrl = method === "POST" ? new URL(url) : undefined; if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 9dbe612b559..f917628f998 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -130,6 +130,12 @@ only a typed DNS-resolution failure degrades to proxy resolution; every literal, resolved-address policy error still rejects. Proxy mode logs once that the proxy-selected peer cannot be pinned. Private destinations additionally require allowPrivateNetwork plus NO_PROXY. +Every request through this wrapper is proxy-originated, so it fills a default +`User-Agent: opencodex` when the request headers name no User-Agent of their own; registry +static headers, provider `headers` values, and vendor-specific client fingerprints keep their +value and spelling. Inference traffic never uses this wrapper, so client fingerprints on +proxied traffic are unaffected (#5104). + Two fake-IP DNS accommodations exist, both for resolved answers only (a literal address in the URL still rejects). The IANA benchmark range (198.18/15 and its IPv4-mapped IPv6 spellings) is admitted whenever any outbound proxy applies to the host, because the range itself marks the answer synthetic. diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index f1613f3703c..d811f4da751 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -559,3 +559,99 @@ describe("effectiveProxyFor picks the variable Bun fetch actually honours", () = expect(effectiveProxyFor(new URL("ftp://x/"), { HTTPS_PROXY: "http://p:7", HTTP_PROXY: "http://p:7" })).toBeNull(); }); }); + +describe("provider outbound default User-Agent", () => { + function userAgentDependencies(response: Response): { + dependencies: ProviderOutboundDependencies; + captured: { headers?: HeadersInit }; + } { + const captured: { headers?: HeadersInit } = {}; + return { + captured, + dependencies: { + resolveAddresses: mock(async () => ({ + hostname: "provider.example", + addresses: [{ address: "93.184.216.34", family: 4 }], + privateNetwork: false, + })), + pinnedGet: mock(async (_url, _pinned, _signal, requestOptions) => { + captured.headers = requestOptions?.headers; + return response; + }), + pinnedPost: mock(async (_url, _pinned, _body, _signal, requestOptions) => { + captured.headers = requestOptions?.headers; + return response; + }), + }, + }; + } + + test("direct GET fills opencodex when no caller names a User-Agent", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response('{"data":[]}', { status: 200 })); + + const response = await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + { headers: { authorization: "Bearer test-key" } }, + dependencies, + ); + + expect(response.status).toBe(200); + expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); + expect(new Headers(captured.headers).get("authorization")).toBe("Bearer test-key"); + }); + + test("a caller User-Agent keeps its value and spelling", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + { headers: { authorization: "Bearer test-key", "user-agent": "gateway-agent/1.0" } }, + dependencies, + ); + + expect(Object.keys(captured.headers as Record).filter(name => name.toLowerCase() === "user-agent")) + .toEqual(["user-agent"]); + expect(new Headers(captured.headers).get("user-agent")).toBe("gateway-agent/1.0"); + }); + + test("no headers at all still sends the default", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + {}, + dependencies, + ); + + expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); + }); + + test("the POST diagnostic path gets the same default", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundPost } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + const response = await providerOutboundPost( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/discovery", + { headers: { authorization: "Bearer test-key" }, body: "{}" }, + dependencies, + ); + + expect(response.status).toBe(200); + expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); + }); +}); From 46566b301ff17c3091bae8cf6501116e08cc6df7 Mon Sep 17 00:00:00 2001 From: mdwsk88 <924038395@qq.com> Date: Sun, 20 Sep 2026 00:24:52 +0800 Subject: [PATCH 2/2] docs(outbound): scope the default User-Agent note to the wrapper surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headers row narrowed the default to model discovery and connection tests, but every request through the provider outbound wrapper carries it — including the Ollama show enrichment — which is what the structure inventory already documents. Align the row with that wording and cover the Headers-instance and array-form header shapes the implementation handles but the suite had not exercised. --- .../docs/reference/configuration/providers.md | 2 +- tests/providers/provider-outbound.test.ts | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 823d0a5536b..7e2c1a8836a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -171,7 +171,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` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | | `modelCosts?` | `Record` | 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` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. Proxy-originated requests — model discovery and connection tests — send `User-Agent: opencodex` unless a User-Agent is set here or by the provider preset. | +| `headers?` | `Record` | 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. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | | `vercelGatewayRouting?` | `VercelGatewayRouting` | Default Vercel AI Gateway `order`, `only`, and `sort` (`"cost"` \| `"ttft"` \| `"tps"`) preferences; valid only for canonical Vercel AI Gateway with `openai-chat`. | diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index d811f4da751..8baa9d22a16 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -654,4 +654,53 @@ describe("provider outbound default User-Agent", () => { expect(response.status).toBe(200); expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); }); + test("a Headers object without a User-Agent gets the default", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + { headers: new Headers({ authorization: "Bearer test-key" }) }, + dependencies, + ); + + expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); + expect(new Headers(captured.headers).get("authorization")).toBe("Bearer test-key"); + }); + + test("an array-form header list without a User-Agent gets the default", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + { headers: [["authorization", "Bearer test-key"]] }, + dependencies, + ); + + expect(new Headers(captured.headers).get("user-agent")).toBe("opencodex"); + expect(new Headers(captured.headers).get("authorization")).toBe("Bearer test-key"); + }); + + test("a Headers object keeps its own User-Agent", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = userAgentDependencies(new Response(null, { status: 200 })); + + await providerOutboundGet( + "custom", + { baseUrl: "https://provider.example/v1" }, + "https://provider.example/v1/models", + { headers: new Headers({ "user-agent": "vendor-agent/1.0" }) }, + dependencies, + ); + + expect(new Headers(captured.headers).get("user-agent")).toBe("vendor-agent/1.0"); + }); });