Skip to content
Draft
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
31 changes: 31 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `headers?` | `Record<string, string>` | 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<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`. |
| `modelVercelGatewayRouting?` | `Record<string, VercelGatewayRouting>` | 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. |
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
resolveTrustedWindowsSystemDirectory,
} from "./lib/windows-elevation";
import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing";
import {
isWirePinnedModel,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
Expand Down Expand Up @@ -1402,6 +1403,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",
Expand Down
108 changes: 108 additions & 0 deletions src/providers/vercel-gateway-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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"]);
const MAX_PROVIDER_SLUGS = 64;

function isPlainRecord(value: unknown): value is Record<string, unknown> {
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) {
const sanitized = sanitizeLogMetadataString(unknown);
return `${field} contains unknown field "${sanitized ?? "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<string>();
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 sanitizedModel = sanitizeLogMetadataString(modelId) ?? "model";
const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizedModel}`);
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<string, unknown> {
return {
...(preference.order ? { order: [...preference.order] } : {}),
...(preference.only ? { only: [...preference.only] } : {}),
...(preference.sort ? { sort: preference.sort } : {}),
};
}
5 changes: 5 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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";

Expand Down Expand Up @@ -635,6 +636,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
Expand Down Expand Up @@ -709,6 +712,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
"modelMaxOutputTokens",
"openRouterRouting",
"modelOpenRouterRouting",
"vercelGatewayRouting",
"modelVercelGatewayRouting",
"reasoningEfforts",
"modelReasoningEfforts",
"reasoningWireFormat",
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type {
export type {
RefreshPolicy,
OpenRouterProviderRouting,
VercelGatewayRouting,
ResponsesItemIdRepairConfig,
RateLimitRetryPolicy,
ProviderCostOverlay,
Expand All @@ -102,4 +103,3 @@ export type {
CodexAccountCredentials,
CodexAccountCredentialRecord,
} from "./types/accounts";

13 changes: 13 additions & 0 deletions src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -295,6 +304,10 @@ export interface OcxProviderConfig {
openRouterRouting?: OpenRouterProviderRouting;
/** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */
modelOpenRouterRouting?: Record<string, OpenRouterProviderRouting>;
/** 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<string, VercelGatewayRouting>;
/**
* "key" (default): authenticate upstream with `apiKey`.
* "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only).
Expand Down
Loading
Loading