@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
|
@@ -215,6 +215,11 @@ export interface ProviderRegistryEntry {
|
|
|
215
215
|
* to stay contiguous. This is seeded/backfilled like other fixed wire capabilities.
|
|
216
216
|
*/
|
|
217
217
|
requiresAdjacentResponsesToolResults?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* When enabled, tool results that are present but empty are annotated on the wire.
|
|
220
|
+
* Seeded/backfilled like other fixed wire capabilities.
|
|
221
|
+
*/
|
|
222
|
+
annotateEmptyToolOutputs?: boolean;
|
|
218
223
|
/**
|
|
219
224
|
* Registry default for the provider's `service_tier` support; see
|
|
220
225
|
* `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
|
|
@@ -454,6 +459,7 @@ const XAI_MODELS = [
|
|
|
454
459
|
"grok-4.6",
|
|
455
460
|
"grok-4.5",
|
|
456
461
|
"grok-4.3",
|
|
462
|
+
"grok-4.20-multi-agent-0309",
|
|
457
463
|
"grok-4.20-0309-reasoning",
|
|
458
464
|
"grok-4.20-0309-non-reasoning",
|
|
459
465
|
"grok-build-0.1",
|
|
@@ -1148,8 +1154,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1148
1154
|
// 260709 refresh: lineup + metadata from official docs.x.ai (grok-4.5 announced 07-08);
|
|
1149
1155
|
// grok-composer-2.5-fast kept as account-verified (absent from public docs). Evidence:
|
|
1150
1156
|
// devlog/model_update/260709_model_refresh/001_xai_lineup.md.
|
|
1151
|
-
// grok-4.20-multi-agent-0309
|
|
1152
|
-
//
|
|
1157
|
+
// 260823: grok-4.20-multi-agent-0309 still returns 400 on Chat Completions, but works
|
|
1158
|
+
// on Responses. The server reports this dated id for both it and the floating
|
|
1159
|
+
// grok-4.20-multi-agent-beta-latest alias, so expose only the dated deployment id.
|
|
1153
1160
|
// 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match
|
|
1154
1161
|
// grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung.
|
|
1155
1162
|
models: XAI_MODELS,
|
|
@@ -1165,9 +1172,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1165
1172
|
// than the seeded ones do.
|
|
1166
1173
|
supportsVerbosity: false,
|
|
1167
1174
|
defaultModel: "grok-4.5",
|
|
1168
|
-
// Keep
|
|
1169
|
-
// opaque reasoning continuation and compaction state across later turns.
|
|
1170
|
-
//
|
|
1175
|
+
// Keep 4.6/4.5 Responses callers on the compatibility Chat wire until xAI can replay
|
|
1176
|
+
// opaque reasoning continuation and compaction state across later turns. Multi-agent has
|
|
1177
|
+
// no Chat wire, so Responses callers use its only working wire under both auth modes.
|
|
1178
|
+
// Caller-owned service tiers stay off the unclassified OAuth subscription route; key-auth
|
|
1179
|
+
// Fast remains proxy-owned and is still selected through keyAuthServiceTier above.
|
|
1171
1180
|
modelWireDefaults: {
|
|
1172
1181
|
"grok-4.6": {
|
|
1173
1182
|
wire: "openai-chat",
|
|
@@ -1181,6 +1190,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1181
1190
|
authModes: ["oauth"],
|
|
1182
1191
|
forwardCallerServiceTier: false,
|
|
1183
1192
|
},
|
|
1193
|
+
"grok-4.20-multi-agent-0309": {
|
|
1194
|
+
// Even at high effort it emits no reasoning-summary deltas or encrypted replay
|
|
1195
|
+
// material. Do not encode that as modelSupportsReasoningSummaries:false: through
|
|
1196
|
+
// Codex #1100 that suppresses the entire reasoning object, including the effort
|
|
1197
|
+
// that controls this model's agent count. An empty summary pane is harmless.
|
|
1198
|
+
// Chat Completions returns 400 for this model, so every inbound uses Responses —
|
|
1199
|
+
// `anthropic` included. Omitting it left providerModelWireDefault returning undefined
|
|
1200
|
+
// for the Claude Messages lane, so resolveWireProtocolOverride kept xAI's provider-wide
|
|
1201
|
+
// openai-chat adapter and sent this model to the wire it 400s on.
|
|
1202
|
+
wire: "openai-responses",
|
|
1203
|
+
inbound: ["responses", "chat", "anthropic"],
|
|
1204
|
+
forwardCallerServiceTier: false,
|
|
1205
|
+
},
|
|
1184
1206
|
},
|
|
1185
1207
|
// Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
|
|
1186
1208
|
// models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
|
|
@@ -1192,6 +1214,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1192
1214
|
"grok-4.6": ["text", "image"],
|
|
1193
1215
|
"grok-4.5": ["text", "image"],
|
|
1194
1216
|
"grok-4.3": ["text", "image"],
|
|
1217
|
+
"grok-4.20-multi-agent-0309": ["text", "image"],
|
|
1195
1218
|
"grok-4.20-0309-reasoning": ["text", "image"],
|
|
1196
1219
|
"grok-4.20-0309-non-reasoning": ["text", "image"],
|
|
1197
1220
|
},
|
|
@@ -1203,13 +1226,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1203
1226
|
preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
|
|
1204
1227
|
// grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh).
|
|
1205
1228
|
// grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning;
|
|
1206
|
-
//
|
|
1207
|
-
|
|
1229
|
+
// multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI
|
|
1230
|
+
// documents high as the 4.6 default but no multi-agent default, so do not invent one.
|
|
1231
|
+
modelReasoningEfforts: {
|
|
1232
|
+
"grok-4.6": ["low", "medium", "high", "xhigh"],
|
|
1233
|
+
"grok-4.5": ["low", "medium", "high"],
|
|
1234
|
+
"grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"],
|
|
1235
|
+
},
|
|
1208
1236
|
modelDefaultReasoningEfforts: { "grok-4.6": "high" },
|
|
1209
1237
|
modelContextWindows: {
|
|
1210
1238
|
"grok-4.6": 500_000,
|
|
1211
1239
|
"grok-4.5": 500_000,
|
|
1212
1240
|
"grok-4.3": 1_000_000,
|
|
1241
|
+
"grok-4.20-multi-agent-0309": 1_000_000,
|
|
1213
1242
|
"grok-4.20-0309-reasoning": 1_000_000,
|
|
1214
1243
|
"grok-4.20-0309-non-reasoning": 1_000_000,
|
|
1215
1244
|
"grok-build-0.1": 256_000,
|
|
@@ -1442,6 +1471,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1442
1471
|
// The DeepSeek vision preview id is metadata-only here: the Go roster is
|
|
1443
1472
|
// discovered live, so it applies the moment the gateway serves the id.
|
|
1444
1473
|
[DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576,
|
|
1474
|
+
// Muse Spark 1.2 Contributor serves a 1,048,576-token (1M) context window over
|
|
1475
|
+
// /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28).
|
|
1476
|
+
// Without this declaration the catalog falls back to 128k, capping real usable context.
|
|
1477
|
+
"muse-spark-1.2-contributor": 1_048_576,
|
|
1445
1478
|
},
|
|
1446
1479
|
modelInputModalities: {
|
|
1447
1480
|
"kimi-k3": ["text", "image"],
|
|
@@ -1780,6 +1813,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1780
1813
|
// context splits a call from its result (#1292); parallel calls remain one
|
|
1781
1814
|
// reasoning-bearing assistant batch rather than being split per pair (#1477).
|
|
1782
1815
|
requiresAdjacentResponsesToolResults: true,
|
|
1816
|
+
// DeepSeek exec tool results can be present-but-empty (a script that ran without
|
|
1817
|
+
// calling text(...)); annotate them so routed models do not silently accept an
|
|
1818
|
+
// empty result or re-issue the same call.
|
|
1819
|
+
annotateEmptyToolOutputs: true,
|
|
1783
1820
|
/* [Decision Log]
|
|
1784
1821
|
- 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
|
|
1785
1822
|
- 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
|
|
@@ -2550,13 +2587,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
2550
2587
|
{
|
|
2551
2588
|
id: "ollama-cloud",
|
|
2552
2589
|
label: "Ollama Cloud",
|
|
2590
|
+
// The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it
|
|
2591
|
+
// to /api/chat, and live model discovery declares its own /v1/models path against the origin,
|
|
2592
|
+
// so the native transport needs no base-URL edit here or in the free-provider directory.
|
|
2553
2593
|
baseUrl: "https://ollama.com/v1",
|
|
2554
|
-
|
|
2594
|
+
// The native transport must be declared HERE, not in configuration. routedProviderConfig()
|
|
2595
|
+
// overwrites provider.adapter with the registry adapter for every row whose transport
|
|
2596
|
+
// matches, so a config-level adapter is silently discarded.
|
|
2597
|
+
adapter: "ollama-native",
|
|
2555
2598
|
authKind: "key",
|
|
2556
2599
|
dashboardUrl: "https://ollama.com/settings/keys",
|
|
2557
2600
|
// Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
|
|
2558
2601
|
models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
|
|
2559
2602
|
defaultModel: "glm-5.3",
|
|
2603
|
+
// Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have
|
|
2604
|
+
// 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep
|
|
2605
|
+
// their existing precedence; these values prevent a failed show from becoming generic.
|
|
2606
|
+
modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 },
|
|
2560
2607
|
noVisionModels: [
|
|
2561
2608
|
// glm-5.3-flash is absent on purpose: native VLM
|
|
2562
2609
|
// (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar.
|
|
@@ -2566,6 +2613,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
2566
2613
|
"deepseek-v4-pro", "deepseek-v4-flash",
|
|
2567
2614
|
"gpt-oss", "qwen3-coder:480b",
|
|
2568
2615
|
],
|
|
2616
|
+
// Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter
|
|
2617
|
+
// never emits one, so a routed row must not inherit the Codex template's verbosity picker.
|
|
2618
|
+
// Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that
|
|
2619
|
+
// arrive later from live discovery must opt out too (the live-discovery gap closed by #2578).
|
|
2620
|
+
supportsVerbosity: false,
|
|
2621
|
+
// Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models,
|
|
2622
|
+
// so the generic discovery pipeline needs no special-casing. The path is spelled against the
|
|
2623
|
+
// ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery
|
|
2624
|
+
// spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which
|
|
2625
|
+
// 307-redirects to /search and discovery falls back to the configured list.
|
|
2626
|
+
modelDiscovery: {
|
|
2627
|
+
path: "/v1/models",
|
|
2628
|
+
},
|
|
2569
2629
|
},
|
|
2570
2630
|
// FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
2571
2631
|
{ id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" },
|
|
@@ -140,13 +140,21 @@ export function resolveSlugSelection(
|
|
|
140
140
|
// selection as provider-qualified made `a/b` resolve against provider "a", so the same
|
|
141
141
|
// collision reported ambiguous through the dash spelling and unambiguous through the slash
|
|
142
142
|
// spelling — the exact asymmetry this resolver exists to remove.
|
|
143
|
-
const
|
|
143
|
+
const matched: string[] = [];
|
|
144
|
+
let exact: string | undefined;
|
|
145
|
+
const ids = [...knownIds];
|
|
146
|
+
// A selection that starts with `<provider>/` is genuinely ambiguous: it reads as the
|
|
147
|
+
// provider-qualified form of `b`, but it is ALSO the native spelling of a self-namespaced
|
|
148
|
+
// id `provider/b`. Stripping the prefix unconditionally erased that second reading, so a
|
|
149
|
+
// published `acme/turbo` became unreachable while a sibling `turbo` silently absorbed the
|
|
150
|
+
// selection. The native id wins when the roster actually publishes it, because only then is
|
|
151
|
+
// the literal spelling known to name a real row.
|
|
152
|
+
const namesNativeId = ids.some(id => id === selection);
|
|
153
|
+
const qualified = !namesNativeId && selection.startsWith(`${provider}/`)
|
|
144
154
|
? selection
|
|
145
155
|
: routedSlug(provider, selection);
|
|
146
156
|
const selectionKey = slugEquivalenceKey(qualified);
|
|
147
|
-
const
|
|
148
|
-
let exact: string | undefined;
|
|
149
|
-
for (const id of knownIds) {
|
|
157
|
+
for (const id of ids) {
|
|
150
158
|
if (slugEquivalenceKey(routedSlug(provider, id)) !== selectionKey) continue;
|
|
151
159
|
matched.push(id);
|
|
152
160
|
if (id === selection || `${provider}/${id}` === selection) exact = id;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { OcxProviderConfig, VercelGatewayRouting } from "../types";
|
|
2
|
+
import { sanitizeLogMetadataString } from "../lib/redact";
|
|
3
|
+
|
|
4
|
+
const ROUTING_KEYS = new Set(["order", "only", "sort"]);
|
|
5
|
+
const SORT_VALUES = new Set(["cost", "ttft", "tps"]);
|
|
6
|
+
const MAX_PROVIDER_SLUGS = 64;
|
|
7
|
+
|
|
8
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
10
|
+
const prototype = Object.getPrototypeOf(value);
|
|
11
|
+
return prototype === Object.prototype || prototype === null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isCanonicalVercelGatewayTarget(baseUrl: string): boolean {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(baseUrl);
|
|
17
|
+
return url.origin === "https://ai-gateway.vercel.sh"
|
|
18
|
+
&& !url.username
|
|
19
|
+
&& !url.password
|
|
20
|
+
&& !url.search
|
|
21
|
+
&& !url.hash
|
|
22
|
+
&& url.pathname.replace(/\/+$/, "") === "/v1";
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function routingPreferenceError(value: unknown, field: string): string | null {
|
|
29
|
+
if (!isPlainRecord(value)) return `${field} must be a plain object`;
|
|
30
|
+
const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key));
|
|
31
|
+
if (unknown) {
|
|
32
|
+
const sanitized = sanitizeLogMetadataString(unknown);
|
|
33
|
+
return `${field} contains unknown field "${sanitized ?? "unknown"}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const listField of ["order", "only"] as const) {
|
|
37
|
+
const list = value[listField];
|
|
38
|
+
if (list === undefined) continue;
|
|
39
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROVIDER_SLUGS) {
|
|
40
|
+
return `${field}.${listField} must contain 1-${MAX_PROVIDER_SLUGS} provider slugs`;
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set<string>();
|
|
43
|
+
for (const slug of list) {
|
|
44
|
+
if (typeof slug !== "string" || !slug.trim() || slug !== slug.trim() || slug.length > 128) {
|
|
45
|
+
return `${field}.${listField} must contain nonblank trimmed provider slugs up to 128 characters`;
|
|
46
|
+
}
|
|
47
|
+
if (seen.has(slug)) return `${field}.${listField} must not contain duplicate provider slugs`;
|
|
48
|
+
seen.add(slug);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (value.sort !== undefined && (typeof value.sort !== "string" || !SORT_VALUES.has(value.sort))) {
|
|
52
|
+
return `${field}.sort must be "cost", "ttft", or "tps"`;
|
|
53
|
+
}
|
|
54
|
+
if (value.order === undefined && value.only === undefined && value.sort === undefined) {
|
|
55
|
+
return `${field} must define order, only, or sort`;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function vercelGatewayRoutingConfigError(provider: OcxProviderConfig): string | null {
|
|
61
|
+
const hasDefault = provider.vercelGatewayRouting !== undefined;
|
|
62
|
+
const hasModels = provider.modelVercelGatewayRouting !== undefined;
|
|
63
|
+
if (!hasDefault && !hasModels) return null;
|
|
64
|
+
if (provider.adapter !== "openai-chat") {
|
|
65
|
+
return "Vercel AI Gateway routing preferences require the openai-chat adapter";
|
|
66
|
+
}
|
|
67
|
+
if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) {
|
|
68
|
+
return "Vercel AI Gateway routing preferences require the canonical https://ai-gateway.vercel.sh/v1 baseUrl";
|
|
69
|
+
}
|
|
70
|
+
if (hasDefault) {
|
|
71
|
+
const error = routingPreferenceError(provider.vercelGatewayRouting, "vercelGatewayRouting");
|
|
72
|
+
if (error) return error;
|
|
73
|
+
}
|
|
74
|
+
if (hasModels) {
|
|
75
|
+
const routes = provider.modelVercelGatewayRouting;
|
|
76
|
+
if (!isPlainRecord(routes)) return "modelVercelGatewayRouting must be a plain object";
|
|
77
|
+
for (const [modelId, preference] of Object.entries(routes)) {
|
|
78
|
+
if (!modelId.trim() || modelId !== modelId.trim()) {
|
|
79
|
+
return "modelVercelGatewayRouting keys must be nonblank trimmed model ids";
|
|
80
|
+
}
|
|
81
|
+
const sanitizedModel = sanitizeLogMetadataString(modelId) ?? "model";
|
|
82
|
+
const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizedModel}`);
|
|
83
|
+
if (error) return error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveVercelGatewayRouting(
|
|
90
|
+
provider: OcxProviderConfig,
|
|
91
|
+
modelId: string,
|
|
92
|
+
): VercelGatewayRouting | undefined {
|
|
93
|
+
if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) return undefined;
|
|
94
|
+
const modelRoutes = provider.modelVercelGatewayRouting;
|
|
95
|
+
return modelRoutes && Object.hasOwn(modelRoutes, modelId)
|
|
96
|
+
? modelRoutes[modelId]
|
|
97
|
+
: provider.vercelGatewayRouting;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function vercelGatewayProviderPayload(
|
|
101
|
+
preference: VercelGatewayRouting,
|
|
102
|
+
): Record<string, unknown> {
|
|
103
|
+
return {
|
|
104
|
+
...(preference.order ? { order: [...preference.order] } : {}),
|
|
105
|
+
...(preference.only ? { only: [...preference.only] } : {}),
|
|
106
|
+
...(preference.sort ? { sort: preference.sort } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./providers/openai-tiers";
|
|
34
34
|
import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
|
|
35
35
|
import { resolveModelAlias } from "./providers/default-aliases";
|
|
36
|
+
import { resolveBlockedModelRedirect } from "./lib/shadow-call";
|
|
36
37
|
import { getStaleCached } from "./codex/model-cache";
|
|
37
38
|
import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
|
|
38
39
|
import {
|
|
@@ -353,6 +354,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
353
354
|
&& registryEntry.requiresAdjacentResponsesToolResults !== undefined
|
|
354
355
|
? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
|
|
355
356
|
: {}),
|
|
357
|
+
...(provider.annotateEmptyToolOutputs === undefined
|
|
358
|
+
&& registryEntry.annotateEmptyToolOutputs !== undefined
|
|
359
|
+
? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs }
|
|
360
|
+
: {}),
|
|
356
361
|
...(provider.fastWire === undefined && registryEntry.fastWire !== undefined
|
|
357
362
|
? {
|
|
358
363
|
fastWire: cloneFastWire(registryEntry.fastWire),
|
|
@@ -489,7 +494,7 @@ export function comboRouteDecisionTrace(
|
|
|
489
494
|
reason: "combo-pick",
|
|
490
495
|
candidateIndex: pick.targetIndex,
|
|
491
496
|
...(combo
|
|
492
|
-
? { tieBreak: combo.strategy
|
|
497
|
+
? { tieBreak: combo.strategy }
|
|
493
498
|
: {}),
|
|
494
499
|
},
|
|
495
500
|
candidates: combo ? comboRouteCandidates(config, pick, combo) : undefined,
|
|
@@ -506,19 +511,23 @@ function isBareOpenAiFamilyModel(modelId: string): boolean {
|
|
|
506
511
|
}
|
|
507
512
|
|
|
508
513
|
function routeResult(
|
|
514
|
+
config: OcxConfig | undefined,
|
|
509
515
|
providerName: string,
|
|
510
516
|
provider: OcxProviderConfig,
|
|
511
517
|
modelId: string,
|
|
512
518
|
routeKind: RouteDecisionKind,
|
|
513
519
|
routeReason: string,
|
|
514
520
|
): RouteResult {
|
|
521
|
+
const redirected = resolveBlockedModelRedirect(config, modelId);
|
|
522
|
+
const effectiveModelId = redirected ?? modelId;
|
|
523
|
+
const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason;
|
|
515
524
|
const codexAccountMode = providerCodexAccountMode(providerName, provider);
|
|
516
525
|
return {
|
|
517
526
|
providerName,
|
|
518
527
|
provider: routedProviderConfig(providerName, provider),
|
|
519
|
-
modelId,
|
|
528
|
+
modelId: effectiveModelId,
|
|
520
529
|
routeKind,
|
|
521
|
-
routeReason,
|
|
530
|
+
routeReason: effectiveRouteReason,
|
|
522
531
|
...(codexAccountMode ? { codexAccountMode } : {}),
|
|
523
532
|
};
|
|
524
533
|
}
|
|
@@ -621,7 +630,7 @@ function routeModelInternal(
|
|
|
621
630
|
throw new NoEnabledOpenAiProviderError(nativeModelId);
|
|
622
631
|
}
|
|
623
632
|
return {
|
|
624
|
-
...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
|
|
633
|
+
...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
|
|
625
634
|
// Exact account injection uses the pool credential machinery even when the canonical
|
|
626
635
|
// provider is globally Direct. The fixed id bypasses pool selection entirely.
|
|
627
636
|
codexAccountMode: "pool",
|
|
@@ -666,7 +675,7 @@ function routeModelInternal(
|
|
|
666
675
|
// itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
|
|
667
676
|
// remainder, which would send a bare `auto` the upstream cannot resolve.
|
|
668
677
|
if (known.includes(modelId)) {
|
|
669
|
-
return routeResult(provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
|
|
678
|
+
return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
|
|
670
679
|
}
|
|
671
680
|
// Codex-facing alias ids (`provider/vendor-model`) decode back to the native
|
|
672
681
|
// slash id via an exact known-id lookup; raw full-slash selectors keep working.
|
|
@@ -676,6 +685,7 @@ function routeModelInternal(
|
|
|
676
685
|
? decoded
|
|
677
686
|
: resolveModelAlias(config, prov, known, requestedModel) ?? decoded;
|
|
678
687
|
return routeResult(
|
|
688
|
+
config,
|
|
679
689
|
provName,
|
|
680
690
|
prov,
|
|
681
691
|
nativeModel,
|
|
@@ -689,7 +699,7 @@ function routeModelInternal(
|
|
|
689
699
|
if (isBareOpenAiFamilyModel(modelId)) {
|
|
690
700
|
const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
|
|
691
701
|
if (provider && provider.disabled !== true) {
|
|
692
|
-
return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
|
|
702
|
+
return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
|
|
693
703
|
}
|
|
694
704
|
throw new NoEnabledOpenAiProviderError(modelId);
|
|
695
705
|
}
|
|
@@ -697,7 +707,7 @@ function routeModelInternal(
|
|
|
697
707
|
for (const [provName, prov] of activeProviderEntries(config)) {
|
|
698
708
|
if (prov.defaultModel === modelId
|
|
699
709
|
|| (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) {
|
|
700
|
-
return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
|
|
710
|
+
return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
|
|
701
711
|
}
|
|
702
712
|
}
|
|
703
713
|
|
|
@@ -708,7 +718,7 @@ function routeModelInternal(
|
|
|
708
718
|
if (prov.models && Array.isArray(prov.models)) {
|
|
709
719
|
const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId);
|
|
710
720
|
if (hit !== undefined) {
|
|
711
|
-
return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list");
|
|
721
|
+
return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list");
|
|
712
722
|
}
|
|
713
723
|
}
|
|
714
724
|
}
|
|
@@ -728,7 +738,7 @@ function routeModelInternal(
|
|
|
728
738
|
}
|
|
729
739
|
if (aliasMatches[0]) {
|
|
730
740
|
const match = aliasMatches[0];
|
|
731
|
-
return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
|
|
741
|
+
return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
|
|
732
742
|
}
|
|
733
743
|
|
|
734
744
|
if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) {
|
|
@@ -737,7 +747,7 @@ function routeModelInternal(
|
|
|
737
747
|
if (hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
738
748
|
const defaultProv = config.providers[config.defaultProvider];
|
|
739
749
|
if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
|
|
740
|
-
return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
|
|
750
|
+
return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
|
|
741
751
|
}
|
|
742
752
|
|
|
743
753
|
throw new Error(`No provider configured for model: ${modelId}`);
|
|
@@ -763,7 +773,7 @@ export function routeModel(
|
|
|
763
773
|
reason: route.routeReason,
|
|
764
774
|
...(route.combo ? { candidateIndex: route.combo.targetIndex } : {}),
|
|
765
775
|
...(combo
|
|
766
|
-
? { tieBreak: combo.strategy
|
|
776
|
+
? { tieBreak: combo.strategy }
|
|
767
777
|
: {}),
|
|
768
778
|
},
|
|
769
779
|
candidates: route.routeKind === "combo" && route.combo && combo
|
|
@@ -786,7 +796,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
|
|
|
786
796
|
);
|
|
787
797
|
if (matchingProvider) {
|
|
788
798
|
const [provName, prov] = matchingProvider;
|
|
789
|
-
return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern");
|
|
799
|
+
return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern");
|
|
790
800
|
}
|
|
791
801
|
// Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by
|
|
792
802
|
// object insertion order, without checking `models`, `selectedModels`, `disabledModels` or
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { extractAccountId } from "../oauth/chatgpt";
|
|
2
3
|
import { formatErrorResponse } from "../bridge";
|
|
3
4
|
import {
|
|
4
5
|
codexAutoStartEnabled,
|
|
@@ -27,6 +28,7 @@ import { providerConfigSeed } from "../providers/derive";
|
|
|
27
28
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
28
29
|
import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
|
|
29
30
|
import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget";
|
|
31
|
+
import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing";
|
|
30
32
|
import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
|
|
31
33
|
import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
|
|
32
34
|
|
|
@@ -403,6 +405,10 @@ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [
|
|
|
403
405
|
{ endpoint: "/v1/chat/completions", bearer: "accepted", dedicated: "accepted", xApiKey: "rejected" },
|
|
404
406
|
{ endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
|
|
405
407
|
{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
|
|
408
|
+
// #809: least-privilege catalog read for remote Codex clients. Same admission set as
|
|
409
|
+
// /v1/models and for the same reason — it forwards no caller credential upstream — so a
|
|
410
|
+
// remote client no longer needs an admin token just to read the model catalog.
|
|
411
|
+
{ endpoint: "/v1/catalog", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
|
|
406
412
|
];
|
|
407
413
|
|
|
408
414
|
/** Whether `token` is the environment-provided management secret. */
|
|
@@ -431,6 +437,14 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
|
|
|
431
437
|
if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
|
|
432
438
|
}
|
|
433
439
|
|
|
440
|
+
/** Whether Authorization carries a caller-owned native Codex credential safe to forward. */
|
|
441
|
+
export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean {
|
|
442
|
+
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
443
|
+
const accountId = headers.get("chatgpt-account-id")?.trim()
|
|
444
|
+
|| (bearer ? extractAccountId(undefined, bearer) : undefined);
|
|
445
|
+
return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config);
|
|
446
|
+
}
|
|
447
|
+
|
|
434
448
|
/**
|
|
435
449
|
* Resolving form of `hasValidApiAuth`: identical header precedence, identical
|
|
436
450
|
* decision, but it names the admission instead of collapsing it to a boolean.
|
|
@@ -568,6 +582,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
568
582
|
delete canonicalCandidate.modelContextWindows;
|
|
569
583
|
// User-owned soft compaction policy; it does not alter the canonical transport seed.
|
|
570
584
|
delete canonicalCandidate.modelAutoCompactTokenLimits;
|
|
585
|
+
// Same category: annotating empty tool outputs is a user-owned request-shaping preference,
|
|
586
|
+
// not part of the canonical transport seed. Without this the field is accepted by
|
|
587
|
+
// validation and then rejected by the seed comparison, so canonical OpenAI could never
|
|
588
|
+
// set OR clear it — the value was admitted and then refused in the same request.
|
|
589
|
+
delete canonicalCandidate.annotateEmptyToolOutputs;
|
|
571
590
|
const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed);
|
|
572
591
|
if (!canonical) {
|
|
573
592
|
return `provider ${name} must equal the canonical built-in provider seed`;
|
|
@@ -636,6 +655,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
636
655
|
if (raw.responsesSnapshotRepair !== undefined && typeof raw.responsesSnapshotRepair !== "boolean") {
|
|
637
656
|
return `provider ${name} responsesSnapshotRepair must be a boolean`;
|
|
638
657
|
}
|
|
658
|
+
if (raw.xaiResponsesXSearch !== undefined && typeof raw.xaiResponsesXSearch !== "boolean") {
|
|
659
|
+
return `provider ${name} xaiResponsesXSearch must be a boolean`;
|
|
660
|
+
}
|
|
639
661
|
const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
|
|
640
662
|
if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
|
|
641
663
|
const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
|
|
@@ -647,6 +669,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
647
669
|
if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
|
|
648
670
|
const openRouterError = openRouterRoutingConfigError(typed);
|
|
649
671
|
if (openRouterError) return `provider ${name} ${openRouterError}`;
|
|
672
|
+
const vercelError = vercelGatewayRoutingConfigError(typed);
|
|
673
|
+
if (vercelError) return `provider ${name} ${vercelError}`;
|
|
650
674
|
if (typed.authMode === "local") {
|
|
651
675
|
// "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/
|
|
652
676
|
// forward as key auth; openai-chat skips credential checks for local). Only providers
|
|
@@ -725,6 +749,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
725
749
|
"modelMaxOutputTokens",
|
|
726
750
|
"openRouterRouting",
|
|
727
751
|
"modelOpenRouterRouting",
|
|
752
|
+
"vercelGatewayRouting",
|
|
753
|
+
"modelVercelGatewayRouting",
|
|
728
754
|
"reasoningEfforts",
|
|
729
755
|
"modelReasoningEfforts",
|
|
730
756
|
"reasoningWireFormat",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared serialization for the model catalog, used by both the management route
|
|
3
|
+
* (`GET /api/catalog`) and the least-privilege data-plane route
|
|
4
|
+
* (`GET|HEAD /v1/catalog`, issue #809).
|
|
5
|
+
*
|
|
6
|
+
* The point of the shared module is that the two routes must emit the *same
|
|
7
|
+
* bytes*. A remote Codex client previously had to be handed an admin token just
|
|
8
|
+
* to read the catalog, which is the least-privilege violation #809 is about; the
|
|
9
|
+
* fix is a second route on the data plane, never a widened management boundary.
|
|
10
|
+
* If each route serialized independently they would drift, and the data-plane
|
|
11
|
+
* copy is the one nobody looks at in the dashboard.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Upper bound for the REMOTE route only.
|
|
17
|
+
*
|
|
18
|
+
* The first version of this used 32 MiB and applied it to both routes, which was
|
|
19
|
+
* wrong twice over. The repository supports up to 2,000 discovered models, and a
|
|
20
|
+
* 2,000-row catalog serializes to roughly 92 MB — so 32 MiB rejected a valid
|
|
21
|
+
* supported catalog, and applying it to `/api/catalog` turned a working
|
|
22
|
+
* management response into a 507 for those operators.
|
|
23
|
+
*
|
|
24
|
+
* 256 MiB clears the supported bound with room to spare while still refusing a
|
|
25
|
+
* file that could only be corrupt or hostile. The management route is not
|
|
26
|
+
* subject to it at all: it is a local dashboard read whose behavior predates
|
|
27
|
+
* this module and must not change.
|
|
28
|
+
*/
|
|
29
|
+
export const MAX_REMOTE_CATALOG_BYTES = 256 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
export interface SerializedCatalog {
|
|
32
|
+
/** Serialized catalog JSON, or null when no catalog could be materialized. */
|
|
33
|
+
body: string | null;
|
|
34
|
+
/** Strong ETag over `body`, present only when `body` is. */
|
|
35
|
+
etag?: string;
|
|
36
|
+
/** Byte length of `body`, present only when `body` is. */
|
|
37
|
+
bytes?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function catalogEtag(body: string): string {
|
|
41
|
+
return `"${createHash("sha256").update(body).digest("hex")}"`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read and serialize the persisted catalog once.
|
|
46
|
+
*
|
|
47
|
+
* Returns `{ body: null }` for every unreadable case — absent file, unreadable
|
|
48
|
+
* file, malformed JSON — because `readCatalog` already collapses those into
|
|
49
|
+
* `null` and the routes render them identically as a 404. Distinguishing them
|
|
50
|
+
* here would invite one route to leak a filesystem path in an error message.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately does NOT apply a size ceiling: a size policy belongs to the route
|
|
53
|
+
* that serves the bytes, not to the shared serializer both planes depend on.
|
|
54
|
+
*/
|
|
55
|
+
export async function serializePersistedCatalog(): Promise<SerializedCatalog> {
|
|
56
|
+
const { readCatalog, readCodexCatalogPath } = await import("../codex/catalog");
|
|
57
|
+
const catalog = readCatalog(readCodexCatalogPath());
|
|
58
|
+
if (!catalog) return { body: null };
|
|
59
|
+
const body = JSON.stringify(catalog);
|
|
60
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
61
|
+
return { body, etag: catalogEtag(body), bytes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The authoritative Codex version for a catalog response, or undefined.
|
|
66
|
+
*
|
|
67
|
+
* Never fabricated: when no runtime is persisted the header is omitted rather
|
|
68
|
+
* than guessed, so a client cannot mistake "unknown" for a specific version.
|
|
69
|
+
*/
|
|
70
|
+
export async function persistedCodexVersion(): Promise<string | undefined> {
|
|
71
|
+
const { loadPersistedCodexRuntime } = await import("../codex/runtime");
|
|
72
|
+
return loadPersistedCodexRuntime()?.selectedVersion ?? undefined;
|
|
73
|
+
}
|
|
@@ -21,6 +21,7 @@ import { isModelTextOnly } from "../vision";
|
|
|
21
21
|
import {
|
|
22
22
|
applyUpstreamRecoveryInit,
|
|
23
23
|
fetchWithResetRetry,
|
|
24
|
+
fetchWithTransientRetry,
|
|
24
25
|
prepareSameTarget429Wait,
|
|
25
26
|
type UpstreamSendRecovery,
|
|
26
27
|
} from "../lib/upstream-retry";
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
rateLimitRetryDelayMs,
|
|
34
35
|
rateLimitRetryPolicyFor,
|
|
35
36
|
rotateProviderTransportOn429,
|
|
37
|
+
transientRetryPolicyFor,
|
|
36
38
|
} from "../providers/key-failover";
|
|
37
39
|
import { fastPolicyForModel } from "../providers/service-tier";
|
|
38
40
|
import type { RouteResult } from "../router";
|
|
@@ -204,7 +206,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
204
206
|
|
|
205
207
|
const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise<Response> => {
|
|
206
208
|
try {
|
|
207
|
-
|
|
209
|
+
// #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on
|
|
210
|
+
// the native chat lane too; everyone else keeps reset-only semantics.
|
|
211
|
+
const transientPolicy = transientRetryPolicyFor(activeProvider);
|
|
212
|
+
const fetchWithPolicy = transientPolicy ? fetchWithTransientRetry : fetchWithResetRetry;
|
|
213
|
+
return await fetchWithPolicy(
|
|
208
214
|
(transportRecovery?: UpstreamSendRecovery) => {
|
|
209
215
|
noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
|
|
210
216
|
return fetchWithHeaderTimeout(
|
|
@@ -223,7 +229,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
223
229
|
}),
|
|
224
230
|
);
|
|
225
231
|
},
|
|
226
|
-
{
|
|
232
|
+
{
|
|
233
|
+
abortSignal: upstream.signal,
|
|
234
|
+
label: safeHostLabel(request.url),
|
|
235
|
+
...(transientPolicy ? { attempts: transientPolicy.attempts } : {}),
|
|
236
|
+
},
|
|
227
237
|
);
|
|
228
238
|
} finally {
|
|
229
239
|
request.releaseBodyObservation?.();
|
package/src/server/gui-static.ts
CHANGED
|
@@ -127,7 +127,10 @@ export function serveGuiFile(
|
|
|
127
127
|
const ext = extname(filePath);
|
|
128
128
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
129
129
|
if (ext === ".html") return htmlResponse(filePath, session);
|
|
130
|
-
|
|
130
|
+
// Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced
|
|
131
|
+
// after Bun frames the response but before the stream finishes, its Content-Length can
|
|
132
|
+
// describe the old file while the body comes from the new one (#2792).
|
|
133
|
+
return new Response(readFileSync(filePath), {
|
|
131
134
|
headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
|
|
132
135
|
});
|
|
133
136
|
}
|