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
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. |
| `modelCosts?` | `Record<string, Cost4>` | 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. |
| `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. |
| `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. |
| `modelOpenRouterRouting?` | `Record<string, OpenRouterProviderRouting>` | 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`. |
Expand Down
44 changes: 43 additions & 1 deletion src/lib/provider-outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
// 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");
Expand Down
6 changes: 6 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
145 changes: 145 additions & 0 deletions tests/providers/provider-outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,148 @@ 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<string, string>).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");
});
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");
});
});
Loading