From 0ae071631c001a324a09064aa789c7f6f1c4ed8f Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 08:38:57 +0000 Subject: [PATCH 1/4] feat(providers): add model-specific provider routing for Vercel AI Gateway (closes #1406) --- src/adapters/openai-chat.ts | 5 + src/config.ts | 15 ++ src/providers/vercel-gateway-routing.ts | 103 ++++++++++++ src/server/auth-cors.ts | 5 + src/types.ts | 1 + src/types/provider.ts | 13 ++ tests/vercel-gateway-provider-routing.test.ts | 150 ++++++++++++++++++ 7 files changed, 292 insertions(+) create mode 100644 src/providers/vercel-gateway-routing.ts create mode 100644 tests/vercel-gateway-provider-routing.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7ad61f1b4d..971f4efe0b 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -12,6 +12,7 @@ import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; +import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { canForwardForeignServiceTierForChatModel, fastPolicyForModel, @@ -115,6 +116,8 @@ export function buildOpenAIChatPassthroughRequest( const openRouterRouting = resolveOpenRouterRouting(provider, modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); + const vercelRouting = resolveVercelGatewayRouting(provider, modelId); + if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; @@ -1368,6 +1371,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const maxTokens = resolveMaxTokens(provider, parsed); const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); + const vercelRouting = resolveVercelGatewayRouting(provider, parsed.modelId); + if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); if (tools) body.tools = tools; if (tools && toolChoice !== undefined) { body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId) diff --git a/src/config.ts b/src/config.ts index 10032fcbcf..3f49e7120e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -60,6 +60,7 @@ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { redactSecretString } from "./lib/redact"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; +import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED, OPENAI_PROVIDER_TIER_VERSION, @@ -1006,6 +1007,20 @@ const configSchema = z.object({ message: openRouterRoutingError, }); } + const vercelRoutingError = vercelGatewayRoutingConfigError(provider); + if (vercelRoutingError) { + ctx.addIssue({ + code: "custom", + path: [ + "providers", + redactSecretString(name), + vercelRoutingError.startsWith("modelVercelGatewayRouting") + ? "modelVercelGatewayRouting" + : "vercelGatewayRouting", + ], + message: vercelRoutingError, + }); + } if (Object.hasOwn(provider, "virtualModels")) { ctx.addIssue({ code: "custom", diff --git a/src/providers/vercel-gateway-routing.ts b/src/providers/vercel-gateway-routing.ts new file mode 100644 index 0000000000..f3f994fa53 --- /dev/null +++ b/src/providers/vercel-gateway-routing.ts @@ -0,0 +1,103 @@ +import type { OcxProviderConfig, VercelGatewayRouting } from "../types"; + +const ROUTING_KEYS = new Set(["order", "only", "sort"]); +const SORT_VALUES = new Set(["cost", "ttft", "tps"]); +const MAX_PROVIDER_SLUGS = 64; + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export function isCanonicalVercelGatewayTarget(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.origin === "https://ai-gateway.vercel.sh" + && !url.username + && !url.password + && !url.search + && !url.hash + && url.pathname.replace(/\/+$/, "") === "/v1"; + } catch { + return false; + } +} + +function routingPreferenceError(value: unknown, field: string): string | null { + if (!isPlainRecord(value)) return `${field} must be a plain object`; + const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key)); + if (unknown) return `${field} contains unknown field "${unknown}"`; + + for (const listField of ["order", "only"] as const) { + const list = value[listField]; + if (list === undefined) continue; + if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROVIDER_SLUGS) { + return `${field}.${listField} must contain 1-${MAX_PROVIDER_SLUGS} provider slugs`; + } + const seen = new Set(); + for (const slug of list) { + if (typeof slug !== "string" || !slug.trim() || slug !== slug.trim() || slug.length > 128) { + return `${field}.${listField} must contain nonblank trimmed provider slugs up to 128 characters`; + } + if (seen.has(slug)) return `${field}.${listField} must not contain duplicate provider slugs`; + seen.add(slug); + } + } + if (value.sort !== undefined && (typeof value.sort !== "string" || !SORT_VALUES.has(value.sort))) { + return `${field}.sort must be "cost", "ttft", or "tps"`; + } + if (value.order === undefined && value.only === undefined && value.sort === undefined) { + return `${field} must define order, only, or sort`; + } + return null; +} + +export function vercelGatewayRoutingConfigError(provider: OcxProviderConfig): string | null { + const hasDefault = provider.vercelGatewayRouting !== undefined; + const hasModels = provider.modelVercelGatewayRouting !== undefined; + if (!hasDefault && !hasModels) return null; + if (provider.adapter !== "openai-chat") { + return "Vercel AI Gateway routing preferences require the openai-chat adapter"; + } + if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) { + return "Vercel AI Gateway routing preferences require the canonical https://ai-gateway.vercel.sh/v1 baseUrl"; + } + if (hasDefault) { + const error = routingPreferenceError(provider.vercelGatewayRouting, "vercelGatewayRouting"); + if (error) return error; + } + if (hasModels) { + const routes = provider.modelVercelGatewayRouting; + if (!isPlainRecord(routes)) return "modelVercelGatewayRouting must be a plain object"; + for (const [modelId, preference] of Object.entries(routes)) { + if (!modelId.trim() || modelId !== modelId.trim()) { + return "modelVercelGatewayRouting keys must be nonblank trimmed model ids"; + } + const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${modelId}`); + if (error) return error; + } + } + return null; +} + +export function resolveVercelGatewayRouting( + provider: OcxProviderConfig, + modelId: string, +): VercelGatewayRouting | undefined { + if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) return undefined; + const modelRoutes = provider.modelVercelGatewayRouting; + return modelRoutes && Object.hasOwn(modelRoutes, modelId) + ? modelRoutes[modelId] + : provider.vercelGatewayRouting; +} + +export function vercelGatewayProviderPayload( + preference: VercelGatewayRouting, +): Record { + return { + ...(preference.order ? { order: [...preference.order] } : {}), + ...(preference.only ? { only: [...preference.only] } : {}), + ...(preference.sort ? { sort: preference.sort } : {}), + }; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2257f78923..f1ada6b465 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -26,6 +26,7 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; @@ -637,6 +638,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; + const vercelError = vercelGatewayRoutingConfigError(typed); + if (vercelError) return `provider ${name} ${vercelError}`; if (typed.authMode === "local") { // "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/ // forward as key auth; openai-chat skips credential checks for local). Only providers @@ -711,6 +714,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelMaxOutputTokens", "openRouterRouting", "modelOpenRouterRouting", + "vercelGatewayRouting", + "modelVercelGatewayRouting", "reasoningEfforts", "modelReasoningEfforts", "reasoningWireFormat", diff --git a/src/types.ts b/src/types.ts index 559e71cbc9..23c37e84d3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export type { export type { RefreshPolicy, OpenRouterProviderRouting, + VercelGatewayRouting, ResponsesItemIdRepairConfig, RateLimitRetryPolicy, ProviderCostOverlay, diff --git a/src/types/provider.ts b/src/types/provider.ts index 3dfca58ddc..67f48371a6 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -17,6 +17,15 @@ export interface OpenRouterProviderRouting { allowFallbacks?: boolean; } +export interface VercelGatewayRouting { + /** Vercel AI Gateway provider slugs to try first, in priority order. */ + order?: string[]; + /** Restrict routing to these Vercel AI Gateway provider slugs. */ + only?: string[]; + /** Sort providers by "cost", "ttft", or "tps". */ + sort?: "cost" | "ttft" | "tps"; +} + export interface ResponsesItemIdRepairConfig { /** Exact `message` item ids that the proxy should rewrite to request-local canonical ids. */ message?: string[]; @@ -295,6 +304,10 @@ export interface OcxProviderConfig { openRouterRouting?: OpenRouterProviderRouting; /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */ modelOpenRouterRouting?: Record; + /** Default provider-routing preferences for models sent through Vercel AI Gateway (issue #1406). */ + vercelGatewayRouting?: VercelGatewayRouting; + /** Exact model-id overrides for `vercelGatewayRouting`. Each matching entry replaces the default. */ + modelVercelGatewayRouting?: Record; /** * "key" (default): authenticate upstream with `apiKey`. * "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only). diff --git a/tests/vercel-gateway-provider-routing.test.ts b/tests/vercel-gateway-provider-routing.test.ts new file mode 100644 index 0000000000..ccdcfb5040 --- /dev/null +++ b/tests/vercel-gateway-provider-routing.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter, buildOpenAIChatPassthroughRequest } from "../src/adapters/openai-chat"; +import { + vercelGatewayRoutingConfigError, + vercelGatewayProviderPayload, +} from "../src/providers/vercel-gateway-routing"; +import { fastPolicyForModel } from "../src/providers/service-tier"; +import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function provider(baseUrl: string, overrides: Partial = {}): OcxProviderConfig { + return { adapter: "openai-chat", baseUrl, apiKey: "test-key", ...overrides }; +} + +function parsed(modelId: string, stream = false): OcxParsedRequest { + return { + modelId, + stream, + context: { messages: [{ role: "user", content: "hello" }], tools: [] }, + options: {}, + }; +} + +function body(baseUrl: string, modelId: string, overrides: Partial = {}, stream = false): Record { + const request = createOpenAIChatAdapter(provider(baseUrl, overrides)).buildRequest(parsed(modelId, stream)); + return JSON.parse(request.body as string) as Record; +} + +function passthroughBody( + providerConfig: OcxProviderConfig, + modelId: string, + rawBody: Record = {}, +): Record { + const request = buildOpenAIChatPassthroughRequest(providerConfig, { + messages: [{ role: "user", content: "hello" }], + ...rawBody, + }, modelId, false, fastPolicyForModel(providerConfig, modelId, undefined, "chat")); + return JSON.parse(request.body as string) as Record; +} + +describe("Vercel AI Gateway configurable provider routing (#1406)", () => { + const vercelLock = { vercelGatewayRouting: { only: ["novita"], sort: "ttft" as const } }; + + test("maps the default preference to Vercel's wire format", () => { + expect(body("https://ai-gateway.vercel.sh/v1", "zai/glm-5.2", vercelLock).provider).toEqual({ + only: ["novita"], sort: "ttft", + }); + }); + + test("an exact model preference replaces the provider-wide default", () => { + const requestBody = body("https://ai-gateway.vercel.sh/v1", "zai/glm-5.2", { + vercelGatewayRouting: { order: ["novita"], sort: "cost" }, + modelVercelGatewayRouting: { + "zai/glm-5.2": { only: ["deepinfra"], order: ["deepinfra"] }, + }, + }); + expect(requestBody.provider).toEqual({ + only: ["deepinfra"], order: ["deepinfra"], + }); + }); + + test("a model without an override inherits the default preference", () => { + const requestBody = body("https://ai-gateway.vercel.sh/v1", "other/model", { + vercelGatewayRouting: { only: ["novita"], sort: "tps" }, + modelVercelGatewayRouting: { + "zai/glm-5.2": { only: ["deepinfra"] }, + }, + }); + expect(requestBody.provider).toEqual({ + only: ["novita"], sort: "tps", + }); + }); + + test("requests without routing configuration omit the provider field", () => { + expect(body("https://ai-gateway.vercel.sh/v1", "zai/glm-5.2").provider).toBeUndefined(); + }); + + test("passthrough request builder applies the routing preference", () => { + const requestBody = passthroughBody( + provider("https://ai-gateway.vercel.sh/v1", { vercelGatewayRouting: { order: ["novita", "deepinfra"] } }), + "zai/glm-5.2", + ); + expect(requestBody.provider).toEqual({ + order: ["novita", "deepinfra"], + }); + }); + + test("config validation accepts valid default and model preferences", () => { + expect(vercelGatewayRoutingConfigError(provider("https://ai-gateway.vercel.sh/v1", { + vercelGatewayRouting: { order: ["novita"], only: ["novita"], sort: "cost" }, + modelVercelGatewayRouting: { + "zai/glm-5.2": { only: ["deepinfra"] }, + }, + }))).toBeNull(); + }); + + test("config validation rejects non-canonical baseUrls", () => { + expect(vercelGatewayRoutingConfigError(provider("https://custom-gateway.test/v1", { + vercelGatewayRouting: { only: ["novita"] }, + }))).toBe("Vercel AI Gateway routing preferences require the canonical https://ai-gateway.vercel.sh/v1 baseUrl"); + }); + + test("config validation rejects non-openai-chat adapter", () => { + expect(vercelGatewayRoutingConfigError({ + adapter: "openai-responses", + baseUrl: "https://ai-gateway.vercel.sh/v1", + vercelGatewayRouting: { only: ["novita"] }, + })).toBe("Vercel AI Gateway routing preferences require the openai-chat adapter"); + }); + + test.each([ + ["an empty preference", { vercelGatewayRouting: {} }, "must define order, only, or sort"], + ["an empty allowlist", { vercelGatewayRouting: { only: [] } }, "must contain 1-64 provider slugs"], + ["duplicate slugs", { vercelGatewayRouting: { only: ["novita", "novita"] } }, "must not contain duplicate"], + ["untrimmed slugs", { vercelGatewayRouting: { order: [" novita"] } }, "nonblank trimmed provider slugs"], + ["an invalid sort value", { vercelGatewayRouting: { sort: "invalid" as never } }, 'must be "cost", "ttft", or "tps"'], + ["unknown fields", { vercelGatewayRouting: { only: ["novita"], typo: true } }, "unknown field"], + ])("config validation rejects %s", (_, override, expected) => { + const error = vercelGatewayRoutingConfigError(provider( + "https://ai-gateway.vercel.sh/v1", + override as Partial, + )); + expect(error).not.toBeNull(); + expect(error).toContain(expected); + }); + + test("safeConfigDTO preserves vercelGatewayRouting in management responses", () => { + const config: OcxConfig = { + port: 10100, + providers: { + "vercel-ai-gateway": { + adapter: "openai-chat", + baseUrl: "https://ai-gateway.vercel.sh/v1", + apiKey: "secret", + vercelGatewayRouting: { only: ["novita"], sort: "ttft" }, + modelVercelGatewayRouting: { + "zai/glm-5.2": { order: ["novita", "deepinfra"] }, + }, + }, + }, + }; + const dto = safeConfigDTO(config); + expect(dto.providers?.["vercel-ai-gateway"]?.vercelGatewayRouting).toEqual({ + only: ["novita"], sort: "ttft", + }); + expect(dto.providers?.["vercel-ai-gateway"]?.modelVercelGatewayRouting).toEqual({ + "zai/glm-5.2": { order: ["novita", "deepinfra"] }, + }); + }); +}); From 895a865dd83338f0c86554d213127348d90bf8dd Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 08:52:59 +0000 Subject: [PATCH 2/4] feat(providers): add model-specific provider routing for Vercel AI Gateway (closes #1406) --- src/providers/vercel-gateway-routing.ts | 5 ++-- src/server/auth-cors.ts | 5 ---- tests/vercel-gateway-provider-routing.test.ts | 25 ------------------- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/src/providers/vercel-gateway-routing.ts b/src/providers/vercel-gateway-routing.ts index f3f994fa53..58909049fa 100644 --- a/src/providers/vercel-gateway-routing.ts +++ b/src/providers/vercel-gateway-routing.ts @@ -1,4 +1,5 @@ import type { OcxProviderConfig, VercelGatewayRouting } from "../types"; +import { sanitizeLogMetadataString } from "../lib/redact"; const ROUTING_KEYS = new Set(["order", "only", "sort"]); const SORT_VALUES = new Set(["cost", "ttft", "tps"]); @@ -27,7 +28,7 @@ export function isCanonicalVercelGatewayTarget(baseUrl: string): boolean { function routingPreferenceError(value: unknown, field: string): string | null { if (!isPlainRecord(value)) return `${field} must be a plain object`; const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key)); - if (unknown) return `${field} contains unknown field "${unknown}"`; + if (unknown) return `${field} contains unknown field "${sanitizeLogMetadataString(unknown)}"`; for (const listField of ["order", "only"] as const) { const list = value[listField]; @@ -74,7 +75,7 @@ export function vercelGatewayRoutingConfigError(provider: OcxProviderConfig): st if (!modelId.trim() || modelId !== modelId.trim()) { return "modelVercelGatewayRouting keys must be nonblank trimmed model ids"; } - const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${modelId}`); + const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizeLogMetadataString(modelId)}`); if (error) return error; } } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index f1ada6b465..2257f78923 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -26,7 +26,6 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; -import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; @@ -638,8 +637,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; - const vercelError = vercelGatewayRoutingConfigError(typed); - if (vercelError) return `provider ${name} ${vercelError}`; if (typed.authMode === "local") { // "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/ // forward as key auth; openai-chat skips credential checks for local). Only providers @@ -714,8 +711,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelMaxOutputTokens", "openRouterRouting", "modelOpenRouterRouting", - "vercelGatewayRouting", - "modelVercelGatewayRouting", "reasoningEfforts", "modelReasoningEfforts", "reasoningWireFormat", diff --git a/tests/vercel-gateway-provider-routing.test.ts b/tests/vercel-gateway-provider-routing.test.ts index ccdcfb5040..1c1c23627f 100644 --- a/tests/vercel-gateway-provider-routing.test.ts +++ b/tests/vercel-gateway-provider-routing.test.ts @@ -5,7 +5,6 @@ import { vercelGatewayProviderPayload, } from "../src/providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../src/providers/service-tier"; -import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; function provider(baseUrl: string, overrides: Partial = {}): OcxProviderConfig { @@ -123,28 +122,4 @@ describe("Vercel AI Gateway configurable provider routing (#1406)", () => { expect(error).not.toBeNull(); expect(error).toContain(expected); }); - - test("safeConfigDTO preserves vercelGatewayRouting in management responses", () => { - const config: OcxConfig = { - port: 10100, - providers: { - "vercel-ai-gateway": { - adapter: "openai-chat", - baseUrl: "https://ai-gateway.vercel.sh/v1", - apiKey: "secret", - vercelGatewayRouting: { only: ["novita"], sort: "ttft" }, - modelVercelGatewayRouting: { - "zai/glm-5.2": { order: ["novita", "deepinfra"] }, - }, - }, - }, - }; - const dto = safeConfigDTO(config); - expect(dto.providers?.["vercel-ai-gateway"]?.vercelGatewayRouting).toEqual({ - only: ["novita"], sort: "ttft", - }); - expect(dto.providers?.["vercel-ai-gateway"]?.modelVercelGatewayRouting).toEqual({ - "zai/glm-5.2": { order: ["novita", "deepinfra"] }, - }); - }); }); From f16849f38b3ae34f74b7d36e9675c587f0778b8e Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 10:34:49 +0000 Subject: [PATCH 3/4] feat(providers): wire vercelGatewayRouting management validation, safeConfigDTO, and docs (#1406) --- .../docs/reference/configuration/providers.md | 31 ++++++++++++++++ src/providers/vercel-gateway-routing.ts | 8 +++-- src/server/auth-cors.ts | 5 +++ tests/vercel-gateway-provider-routing.test.ts | 36 +++++++++++++++++++ 4 files changed, 78 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 c44b628714..7ce9076e9e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -91,6 +91,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `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`. | +| `modelVercelGatewayRouting?` | `Record` | Exact model-id overrides that replace the provider-wide Vercel AI Gateway preference. | | `authMode?` | `"key" \| "forward" \| "oauth" \| "local"` | Authentication mode (default `key`). OAuth/subscription credentials are stored outside `config.json`; `local` is limited to providers whose registry entry permits it. | | `codexAccountMode?` | `"pool" \| "direct"` | Canonical `openai` only; defaults to Pool. Direct bypasses pool state. | | `refreshPolicy?` | `"proactive" \| "lazy-only" \| "disabled"` | Override this OAuth provider's Token Guardian policy. | @@ -444,6 +446,35 @@ eligible provider after the ordered list. `only` is always an allowlist. } ``` +## Vercel AI Gateway provider routing + +Vercel AI Gateway can route a model across multiple underlying inference providers. `vercelGatewayRouting` configures provider-wide preferences; `modelVercelGatewayRouting` replaces it for exact model IDs. + +- `order`: provider slugs in priority order. +- `only`: explicit allowlist restricting eligible providers. +- `sort`: automatically sort eligible providers by `"cost"`, `"ttft"`, or `"tps"`. + +```json +{ + "providers": { + "vercel-ai-gateway": { + "adapter": "openai-chat", + "baseUrl": "https://ai-gateway.vercel.sh/v1", + "apiKey": "${VERCEL_AI_GATEWAY_KEY}", + "vercelGatewayRouting": { + "sort": "ttft" + }, + "modelVercelGatewayRouting": { + "zai/glm-5.2": { + "only": ["novita", "deepinfra"], + "order": ["novita", "deepinfra"] + } + } + } + } +} +``` + Model keys are exact native OpenRouter ids, without the outer opencodex provider prefix. Selecting `openrouter/anthropic-claude-sonnet-5` restores native `anthropic/claude-sonnet-5` before applying the model rule. diff --git a/src/providers/vercel-gateway-routing.ts b/src/providers/vercel-gateway-routing.ts index 58909049fa..ac2e2ff56f 100644 --- a/src/providers/vercel-gateway-routing.ts +++ b/src/providers/vercel-gateway-routing.ts @@ -28,7 +28,10 @@ export function isCanonicalVercelGatewayTarget(baseUrl: string): boolean { function routingPreferenceError(value: unknown, field: string): string | null { if (!isPlainRecord(value)) return `${field} must be a plain object`; const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key)); - if (unknown) return `${field} contains unknown field "${sanitizeLogMetadataString(unknown)}"`; + if (unknown) { + const sanitized = sanitizeLogMetadataString(unknown); + return `${field} contains unknown field "${sanitized ?? "unknown"}"`; + } for (const listField of ["order", "only"] as const) { const list = value[listField]; @@ -75,7 +78,8 @@ export function vercelGatewayRoutingConfigError(provider: OcxProviderConfig): st if (!modelId.trim() || modelId !== modelId.trim()) { return "modelVercelGatewayRouting keys must be nonblank trimmed model ids"; } - const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizeLogMetadataString(modelId)}`); + const sanitizedModel = sanitizeLogMetadataString(modelId) ?? "model"; + const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizedModel}`); if (error) return error; } } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2257f78923..f1ada6b465 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -26,6 +26,7 @@ import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in"; @@ -637,6 +638,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`; const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; + const vercelError = vercelGatewayRoutingConfigError(typed); + if (vercelError) return `provider ${name} ${vercelError}`; if (typed.authMode === "local") { // "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/ // forward as key auth; openai-chat skips credential checks for local). Only providers @@ -711,6 +714,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelMaxOutputTokens", "openRouterRouting", "modelOpenRouterRouting", + "vercelGatewayRouting", + "modelVercelGatewayRouting", "reasoningEfforts", "modelReasoningEfforts", "reasoningWireFormat", diff --git a/tests/vercel-gateway-provider-routing.test.ts b/tests/vercel-gateway-provider-routing.test.ts index 1c1c23627f..295a8cfced 100644 --- a/tests/vercel-gateway-provider-routing.test.ts +++ b/tests/vercel-gateway-provider-routing.test.ts @@ -5,6 +5,7 @@ import { vercelGatewayProviderPayload, } from "../src/providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../src/providers/service-tier"; +import { safeConfigDTO, providerManagementConfigError } from "../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; function provider(baseUrl: string, overrides: Partial = {}): OcxProviderConfig { @@ -107,6 +108,41 @@ describe("Vercel AI Gateway configurable provider routing (#1406)", () => { })).toBe("Vercel AI Gateway routing preferences require the openai-chat adapter"); }); + test("safeConfigDTO preserves vercelGatewayRouting and modelVercelGatewayRouting", () => { + const config: OcxConfig = { + providers: { + "vercel-ai-gateway": provider("https://ai-gateway.vercel.sh/v1", { + vercelGatewayRouting: { sort: "ttft" }, + modelVercelGatewayRouting: { + "zai/glm-5.2": { only: ["novita"] }, + }, + }), + }, + } as unknown as OcxConfig; + const dto = safeConfigDTO(config) as { providers: Record> }; + expect(dto.providers["vercel-ai-gateway"].vercelGatewayRouting).toEqual({ sort: "ttft" }); + expect(dto.providers["vercel-ai-gateway"].modelVercelGatewayRouting).toEqual({ + "zai/glm-5.2": { only: ["novita"] }, + }); + }); + + test("providerManagementConfigError validates vercelGatewayRouting on management writes", () => { + const valid = providerManagementConfigError("vercel-ai-gateway", { + adapter: "openai-chat", + baseUrl: "https://ai-gateway.vercel.sh/v1", + vercelGatewayRouting: { only: ["novita"] }, + }); + expect(valid).toBeNull(); + + const invalid = providerManagementConfigError("vercel-ai-gateway", { + adapter: "openai-chat", + baseUrl: "https://ai-gateway.vercel.sh/v1", + vercelGatewayRouting: { only: [] }, + }); + expect(invalid).not.toBeNull(); + expect(invalid).toContain("must contain 1-64 provider slugs"); + }); + test.each([ ["an empty preference", { vercelGatewayRouting: {} }, "must define order, only, or sort"], ["an empty allowlist", { vercelGatewayRouting: { only: [] } }, "must contain 1-64 provider slugs"], From d9ef3cd52a5f59e979c2474b10f12f926fac1f34 Mon Sep 17 00:00:00 2001 From: chilung Date: Sun, 23 Aug 2026 11:34:49 +0000 Subject: [PATCH 4/4] test(vercel): cover routed model preference selectors --- .../docs/reference/configuration/providers.md | 8 +++--- tests/vercel-gateway-provider-routing.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 7ce9076e9e..6ea5262004 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -475,9 +475,11 @@ Vercel AI Gateway can route a model across multiple underlying inference provide } ``` -Model keys are exact native OpenRouter ids, without the outer opencodex provider prefix. Selecting -`openrouter/anthropic-claude-sonnet-5` restores native `anthropic/claude-sonnet-5` before applying -the model rule. +Model keys are Vercel public model selectors without the outer OpenCodex provider prefix. Selecting +`vercel-ai-gateway/zai-glm-5.2` restores native `zai/glm-5.2` before applying the model rule. The +same mapping applies to a native `vercel/` selector: use the encoded +`vercel-ai-gateway/vercel-` selector in OpenCodex and keep `vercel/` as the +model key. ## Static model allowlists diff --git a/tests/vercel-gateway-provider-routing.test.ts b/tests/vercel-gateway-provider-routing.test.ts index 295a8cfced..d031ba61c9 100644 --- a/tests/vercel-gateway-provider-routing.test.ts +++ b/tests/vercel-gateway-provider-routing.test.ts @@ -5,6 +5,7 @@ import { vercelGatewayProviderPayload, } from "../src/providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../src/providers/service-tier"; +import { routeModel } from "../src/router"; import { safeConfigDTO, providerManagementConfigError } from "../src/server/auth-cors"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -59,6 +60,32 @@ describe("Vercel AI Gateway configurable provider routing (#1406)", () => { }); }); + test("routes the public Vercel selector before applying model routing on both Chat paths", () => { + const nativeModelId = "zai/glm-5.2"; + const modelPreference = { + only: ["novita"], + order: ["novita", "deepinfra"], + sort: "ttft" as const, + }; + const config: OcxConfig = { + port: 10100, + defaultProvider: "vercel-ai-gateway", + providers: { + "vercel-ai-gateway": provider("https://ai-gateway.vercel.sh/v1", { + models: [nativeModelId], + vercelGatewayRouting: { sort: "cost" }, + modelVercelGatewayRouting: { [nativeModelId]: modelPreference }, + }), + }, + }; + + const route = routeModel(config, "vercel-ai-gateway/zai-glm-5.2"); + expect(route.modelId).toBe(nativeModelId); + expect(JSON.parse(createOpenAIChatAdapter(route.provider).buildRequest(parsed(route.modelId)).body as string).provider) + .toEqual(modelPreference); + expect(passthroughBody(route.provider, route.modelId).provider).toEqual(modelPreference); + }); + test("a model without an override inherits the default preference", () => { const requestBody = body("https://ai-gateway.vercel.sh/v1", "other/model", { vercelGatewayRouting: { only: ["novita"], sort: "tps" },