@bitkyc08/opencodex 2.10.2 → 2.11.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/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
|
@@ -196,6 +196,8 @@ export interface ProviderRegistryEntry {
|
|
|
196
196
|
supportsServiceTier?: boolean;
|
|
197
197
|
/** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */
|
|
198
198
|
preserveResponsesReasoningContent?: boolean;
|
|
199
|
+
/** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */
|
|
200
|
+
modelSupportsReasoningSummaries?: Record<string, boolean>;
|
|
199
201
|
modelDiscovery?: ProviderModelDiscoverySpec;
|
|
200
202
|
contextWindow?: number;
|
|
201
203
|
modelContextWindows?: Record<string, number>;
|
|
@@ -719,6 +721,62 @@ const BASETEN_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
|
|
|
719
721
|
"moonshotai/Kimi-K2.7-Code": ["text", "image"],
|
|
720
722
|
"moonshotai/Kimi-K3": ["text", "image"],
|
|
721
723
|
};
|
|
724
|
+
|
|
725
|
+
// 260801 DigitalOcean and Scaleway expose OpenAI-shaped `/v1/models` rows with only
|
|
726
|
+
// id/object/created/owned_by, while their shared serverless catalogs also contain
|
|
727
|
+
// non-chat and endpoint-specific models. Fail closed by intersecting live discovery
|
|
728
|
+
// with ids that the providers' current first-party model tables establish for Chat
|
|
729
|
+
// Completions. A newly listed id therefore needs a docs-backed registry refresh before
|
|
730
|
+
// it can enter the Codex catalog.
|
|
731
|
+
// Evidence: https://docs.digitalocean.com/products/inference/details/models/
|
|
732
|
+
// https://docs.digitalocean.com/reference/api/reference/serverless-inference/
|
|
733
|
+
// https://www.scaleway.com/en/docs/generative-apis/reference-content/supported-models/
|
|
734
|
+
const DIGITALOCEAN_CHAT_COMPLETION_MODELS = [
|
|
735
|
+
"arcee-trinity-large-thinking",
|
|
736
|
+
"openai-gpt-5.6-sol",
|
|
737
|
+
"openai-gpt-5.6-terra",
|
|
738
|
+
"openai-gpt-5.6-luna",
|
|
739
|
+
"qwen3-coder-flash",
|
|
740
|
+
"qwen3.5-397b-a17b",
|
|
741
|
+
"deepseek-v4-pro",
|
|
742
|
+
"deepseek-4-flash",
|
|
743
|
+
"deepseek-3.2",
|
|
744
|
+
"gemma-4-31B-it",
|
|
745
|
+
"minimax-m2.5",
|
|
746
|
+
"kimi-k3",
|
|
747
|
+
"kimi-k2.6",
|
|
748
|
+
"kimi-k2.5",
|
|
749
|
+
"llama3.3-70b-instruct",
|
|
750
|
+
"llama-4-maverick",
|
|
751
|
+
"mistral-3-14B",
|
|
752
|
+
"nemotron-3-ultra-550b",
|
|
753
|
+
"nvidia-nemotron-3-super-120b",
|
|
754
|
+
"nemotron-3-nano-omni",
|
|
755
|
+
"nemotron-nano-12b-v2-vl",
|
|
756
|
+
"mimo-v2.5-pro",
|
|
757
|
+
"glm-5.2",
|
|
758
|
+
"glm-5.1",
|
|
759
|
+
"glm-5",
|
|
760
|
+
// The API reference uses this native slash id in its Chat Completions example.
|
|
761
|
+
"meta-llama/Meta-Llama-3.1-8B-Instruct",
|
|
762
|
+
] as const;
|
|
763
|
+
const SCALEWAY_SERVERLESS_CHAT_MODELS = [
|
|
764
|
+
"glm-5.2",
|
|
765
|
+
// gpt-oss-120b is intentionally omitted: Scaleway requires Responses API for tool calling,
|
|
766
|
+
// while this preset routes Codex agent tools through Chat Completions.
|
|
767
|
+
"qwen3.6-35b-a3b",
|
|
768
|
+
"qwen3.5-397b-a17b",
|
|
769
|
+
"qwen3-235b-a22b-instruct-2507",
|
|
770
|
+
"qwen3-coder-30b-a3b-instruct",
|
|
771
|
+
"gemma-4-26b-a4b-it",
|
|
772
|
+
"llama-3.3-70b-instruct",
|
|
773
|
+
"mistral-medium-3.5-128b",
|
|
774
|
+
"mistral-small-3.2-24b-instruct-2506",
|
|
775
|
+
"pixtral-12b-2409",
|
|
776
|
+
] as const;
|
|
777
|
+
const SCALEWAY_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
|
|
778
|
+
"pixtral-12b-2409": ["text", "image"],
|
|
779
|
+
};
|
|
722
780
|
const UMANS_MODELS = [
|
|
723
781
|
"umans-coder",
|
|
724
782
|
"umans-kimi-k2.7",
|
|
@@ -1048,6 +1106,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1048
1106
|
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
|
|
1049
1107
|
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
|
|
1050
1108
|
},
|
|
1109
|
+
modelSupportsReasoningSummaries: {
|
|
1110
|
+
"glm-5.2": true,
|
|
1111
|
+
"glm-5.1": true,
|
|
1112
|
+
"glm-5": true,
|
|
1113
|
+
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])),
|
|
1114
|
+
},
|
|
1051
1115
|
thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
|
|
1052
1116
|
thinkingBudgetModels: THINKING_BUDGET_MODELS,
|
|
1053
1117
|
noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
@@ -1251,10 +1315,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1251
1315
|
// for no gain.
|
|
1252
1316
|
"deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] },
|
|
1253
1317
|
},
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
// response
|
|
1257
|
-
|
|
1318
|
+
// The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired
|
|
1319
|
+
// for this entry: the official guide documents a `response.completed` /
|
|
1320
|
+
// `response.incomplete` / `response.failed` terminal with NO `data: [DONE]`
|
|
1321
|
+
// sentinel, and live probes (2026-08-07, including the tool-result replay shape
|
|
1322
|
+
// that originally stalled) close on the terminal. The relay's terminal boundary
|
|
1323
|
+
// (src/server/relay.ts) already cuts the stream at that event and synthesizes
|
|
1324
|
+
// `[DONE]`, so forcing stream:false only delayed every byte until generation
|
|
1325
|
+
// finished (28-46 s of silence on long turns). The registry knob itself remains
|
|
1326
|
+
// for providers that need it — re-adding one line here restores the old policy.
|
|
1327
|
+
// Evidence: https://api-docs.deepseek.com/guides/responses_api/ +
|
|
1328
|
+
// devlog/_plan/260807_deepseek_responses_streaming/000_plan.md.
|
|
1258
1329
|
// DeepSeek's Responses route emits bare UUID item ids, which leave Codex
|
|
1259
1330
|
// clients stuck on an uncommitted turn (#938). Client-facing only — raw
|
|
1260
1331
|
// continuation snapshots keep the upstream ids.
|
|
@@ -1284,6 +1355,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1284
1355
|
*/
|
|
1285
1356
|
modelReasoningEfforts: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
|
|
1286
1357
|
modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])),
|
|
1358
|
+
modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, true])),
|
|
1287
1359
|
preserveReasoningContentModels: DEEPSEEK_THINKING_MODELS,
|
|
1288
1360
|
// Issue #88: every DeepSeek API model is text-only input (no image support upstream) — the
|
|
1289
1361
|
// vision sidecar describes attached images for them, and the catalog advertises image input
|
|
@@ -1329,6 +1401,69 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1329
1401
|
},
|
|
1330
1402
|
note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.",
|
|
1331
1403
|
},
|
|
1404
|
+
{
|
|
1405
|
+
// Primary sources checked 2026-08-03:
|
|
1406
|
+
// - docs.nscale.com documents the production OpenAI-compatible endpoint, bearer service
|
|
1407
|
+
// tokens, /v1/models, and a tool-calling request using this exact Llama model id.
|
|
1408
|
+
// - nscale.com/policies/terms-conditions identifies Nscale AS as the service operator and
|
|
1409
|
+
// covers customers using its public-cloud inference offering. Maintainer: @olddonkey;
|
|
1410
|
+
// no affiliation with Nscale.
|
|
1411
|
+
id: "nscale",
|
|
1412
|
+
label: "Nscale Serverless Inference",
|
|
1413
|
+
baseUrl: "https://inference.api.nscale.com/v1",
|
|
1414
|
+
adapter: "openai-chat",
|
|
1415
|
+
authKind: "key",
|
|
1416
|
+
dashboardUrl: "https://console.nscale.com",
|
|
1417
|
+
defaultModel: "meta-llama/Llama-3.1-8B-Instruct",
|
|
1418
|
+
models: ["meta-llama/Llama-3.1-8B-Instruct"],
|
|
1419
|
+
liveModels: true,
|
|
1420
|
+
preserveCustomDestination: true,
|
|
1421
|
+
// Nscale documents tools but not parallel tool calls. Keep requests serialized.
|
|
1422
|
+
parallelToolCalls: false,
|
|
1423
|
+
// The API schema accepts reasoning_effort, but does not publish per-model tiers.
|
|
1424
|
+
reasoningEfforts: [],
|
|
1425
|
+
modelDiscovery: {
|
|
1426
|
+
path: "models",
|
|
1427
|
+
maxResponseBytes: 256 * 1024,
|
|
1428
|
+
maxModels: 256,
|
|
1429
|
+
filter: {
|
|
1430
|
+
// Nscale's catalog mixes chat, image, and embedding rows without a modality field.
|
|
1431
|
+
// Admit only the exact model used in its official tool-calling API example.
|
|
1432
|
+
allOf: [{ path: ["id"], equalsAny: ["meta-llama/Llama-3.1-8B-Instruct"] }],
|
|
1433
|
+
},
|
|
1434
|
+
},
|
|
1435
|
+
note: "Serverless OpenAI-compatible inference. Live discovery admits only the tool-capable model established by Nscale's official API example; other mixed-catalog rows remain hidden pending equivalent evidence.",
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
// Primary sources checked 2026-08-03:
|
|
1439
|
+
// - docs.vultr.com documents the fixed OpenAI-compatible base URL, per-subscription bearer
|
|
1440
|
+
// key, /v1/models, and states that tool calling is currently limited to kimi-k2-instruct.
|
|
1441
|
+
// - Vultr's official properties identify VULTR as a The Constant Company, LLC trademark and
|
|
1442
|
+
// document customer API integrations. Maintainer: @olddonkey; no affiliation with Vultr.
|
|
1443
|
+
id: "vultr",
|
|
1444
|
+
label: "Vultr Serverless Inference",
|
|
1445
|
+
baseUrl: "https://api.vultrinference.com/v1",
|
|
1446
|
+
adapter: "openai-chat",
|
|
1447
|
+
authKind: "key",
|
|
1448
|
+
dashboardUrl: "https://my.vultr.com",
|
|
1449
|
+
defaultModel: "kimi-k2-instruct",
|
|
1450
|
+
models: ["kimi-k2-instruct"],
|
|
1451
|
+
liveModels: true,
|
|
1452
|
+
preserveCustomDestination: true,
|
|
1453
|
+
parallelToolCalls: false,
|
|
1454
|
+
reasoningEfforts: [],
|
|
1455
|
+
modelDiscovery: {
|
|
1456
|
+
path: "models",
|
|
1457
|
+
maxResponseBytes: 256 * 1024,
|
|
1458
|
+
maxModels: 256,
|
|
1459
|
+
filter: {
|
|
1460
|
+
// Vultr explicitly limits tool calling to this model. A coding agent must not select
|
|
1461
|
+
// another chat model that cannot complete its tool loop.
|
|
1462
|
+
allOf: [{ path: ["id"], equalsAny: ["kimi-k2-instruct"] }],
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.",
|
|
1466
|
+
},
|
|
1332
1467
|
{
|
|
1333
1468
|
id: "baseten",
|
|
1334
1469
|
label: "Baseten Model APIs",
|
|
@@ -1380,6 +1515,101 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1380
1515
|
// 401 UNAUTHORIZED without a Bearer key. Primary source: https://commandcode.ai/docs/provider.
|
|
1381
1516
|
note: "Command Code Provider API (OpenAI-compatible); API access requires the Provider plan. Use `ocx login command-code` for OAuth account login (imports an existing local Command Code CLI credential when present). Docs: https://commandcode.ai/docs/provider.",
|
|
1382
1517
|
},
|
|
1518
|
+
{
|
|
1519
|
+
id: "sambanova",
|
|
1520
|
+
label: "SambaNova Cloud",
|
|
1521
|
+
baseUrl: "https://api.sambanova.ai/v1",
|
|
1522
|
+
adapter: "openai-chat",
|
|
1523
|
+
authKind: "key",
|
|
1524
|
+
dashboardUrl: "https://cloud.sambanova.ai/apis",
|
|
1525
|
+
liveModels: true,
|
|
1526
|
+
preserveCustomDestination: true,
|
|
1527
|
+
apiKeyValidation: "unknown",
|
|
1528
|
+
// SambaNova documents this request field but does not yet support parallel function calls.
|
|
1529
|
+
parallelToolCalls: false,
|
|
1530
|
+
// The public catalog does not report a trustworthy per-model reasoning contract.
|
|
1531
|
+
reasoningEfforts: [],
|
|
1532
|
+
modelDiscovery: {
|
|
1533
|
+
path: "models",
|
|
1534
|
+
maxResponseBytes: 128 * 1024,
|
|
1535
|
+
maxModels: 128,
|
|
1536
|
+
},
|
|
1537
|
+
note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.",
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
id: "nebius",
|
|
1541
|
+
label: "Nebius Token Factory",
|
|
1542
|
+
baseUrl: "https://api.tokenfactory.nebius.com/v1",
|
|
1543
|
+
adapter: "openai-chat",
|
|
1544
|
+
authKind: "key",
|
|
1545
|
+
dashboardUrl: "https://tokenfactory.nebius.com",
|
|
1546
|
+
liveModels: true,
|
|
1547
|
+
preserveCustomDestination: true,
|
|
1548
|
+
// The public tools guide documents single function selection, not parallel tool calls.
|
|
1549
|
+
parallelToolCalls: false,
|
|
1550
|
+
// Missing reasoning metadata must not promote a model to Codex's full fallback ladder.
|
|
1551
|
+
reasoningEfforts: [],
|
|
1552
|
+
modelDiscovery: {
|
|
1553
|
+
path: "models",
|
|
1554
|
+
query: { verbose: "true" },
|
|
1555
|
+
maxResponseBytes: 512 * 1024,
|
|
1556
|
+
maxModels: 512,
|
|
1557
|
+
filter: {
|
|
1558
|
+
// Keep rows whose reported architecture output includes text (for example,
|
|
1559
|
+
// text->text or text+image->text); embedding and image-generation rows are excluded.
|
|
1560
|
+
allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }],
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
note: "Shared Token Factory text-output inference only; live discovery excludes embedding and image-generation rows.",
|
|
1564
|
+
},
|
|
1565
|
+
{
|
|
1566
|
+
id: "digitalocean",
|
|
1567
|
+
label: "DigitalOcean Serverless Inference",
|
|
1568
|
+
baseUrl: "https://inference.do-ai.run/v1",
|
|
1569
|
+
adapter: "openai-chat",
|
|
1570
|
+
authKind: "key",
|
|
1571
|
+
dashboardUrl: "https://cloud.digitalocean.com/model-studio/manage-keys",
|
|
1572
|
+
liveModels: true,
|
|
1573
|
+
preserveCustomDestination: true,
|
|
1574
|
+
// The Chat Completions contract documents function calls but not universal parallel support.
|
|
1575
|
+
parallelToolCalls: false,
|
|
1576
|
+
// Unknown catalog rows must not inherit Codex's full fallback reasoning ladder.
|
|
1577
|
+
reasoningEfforts: [],
|
|
1578
|
+
modelDiscovery: {
|
|
1579
|
+
path: "models",
|
|
1580
|
+
maxResponseBytes: 256 * 1024,
|
|
1581
|
+
maxModels: 256,
|
|
1582
|
+
filter: {
|
|
1583
|
+
allOf: [{ path: ["id"], equalsAny: DIGITALOCEAN_CHAT_COMPLETION_MODELS }],
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
note: "Shared Serverless Inference Chat Completions only; agent-specific, dedicated, Responses-only, embedding, and media-generation models are outside this preset.",
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
id: "scaleway",
|
|
1590
|
+
label: "Scaleway Generative APIs",
|
|
1591
|
+
baseUrl: "https://api.scaleway.ai/v1",
|
|
1592
|
+
adapter: "openai-chat",
|
|
1593
|
+
authKind: "key",
|
|
1594
|
+
dashboardUrl: "https://console.scaleway.com/generative-api",
|
|
1595
|
+
liveModels: true,
|
|
1596
|
+
freeTier: true,
|
|
1597
|
+
preserveCustomDestination: true,
|
|
1598
|
+
// Parallel support varies by model; avoid advertising it as a provider-wide capability.
|
|
1599
|
+
parallelToolCalls: false,
|
|
1600
|
+
// The generic `/models` rows carry no trustworthy reasoning metadata.
|
|
1601
|
+
reasoningEfforts: [],
|
|
1602
|
+
modelInputModalities: SCALEWAY_MODEL_INPUT_MODALITIES,
|
|
1603
|
+
modelDiscovery: {
|
|
1604
|
+
path: "models",
|
|
1605
|
+
maxResponseBytes: 128 * 1024,
|
|
1606
|
+
maxModels: 128,
|
|
1607
|
+
filter: {
|
|
1608
|
+
allOf: [{ path: ["id"], equalsAny: SCALEWAY_SERVERLESS_CHAT_MODELS }],
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.",
|
|
1612
|
+
},
|
|
1383
1613
|
// FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
1384
1614
|
{ id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
|
|
1385
1615
|
{ id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
|
|
@@ -1439,6 +1669,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1439
1669
|
modelSuffixBracketStrip: true,
|
|
1440
1670
|
noVisionModels: ZAI_GLM_52_MODELS,
|
|
1441
1671
|
modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])),
|
|
1672
|
+
modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, true])),
|
|
1442
1673
|
preserveReasoningContentModels: ZAI_GLM_52_MODELS,
|
|
1443
1674
|
},
|
|
1444
1675
|
// Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a
|
|
@@ -1475,11 +1706,50 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1475
1706
|
modelReasoningEffortMap: Object.fromEntries(
|
|
1476
1707
|
ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP]),
|
|
1477
1708
|
),
|
|
1709
|
+
modelSupportsReasoningSummaries: Object.fromEntries(
|
|
1710
|
+
ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]),
|
|
1711
|
+
),
|
|
1478
1712
|
preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS,
|
|
1479
1713
|
// No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a
|
|
1480
1714
|
// false live claim yields an empty picker at runtime. Flip it on once someone verifies it.
|
|
1481
1715
|
note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)",
|
|
1482
1716
|
},
|
|
1717
|
+
// BigModel's Coding Plan is a SEPARATE endpoint from the pay-as-you-go row above, and that is
|
|
1718
|
+
// the whole reason this one exists. #1100 was reported against
|
|
1719
|
+
// `https://open.bigmodel.cn/api/coding/paas/v4`; the row above covers only `/api/paas/v4`, so
|
|
1720
|
+
// destination enrichment matched nothing, `modelSupportsReasoningSummaries` stayed unset, and
|
|
1721
|
+
// Codex kept dropping the inbound reasoning object — effort displayed as `-`.
|
|
1722
|
+
//
|
|
1723
|
+
// A prefix or fuzzy endpoint match would have been the shortcut. It is also how a config
|
|
1724
|
+
// pointed at one vendor route silently inherits another route's metadata, so endpoints stay
|
|
1725
|
+
// exact and each one gets its own row.
|
|
1726
|
+
//
|
|
1727
|
+
// The id is NOT `glm-cn`, which the free-provider directory already binds to this same coding
|
|
1728
|
+
// path: registering it here would let routedProviderConfig() canonicalize a saved `glm-cn`
|
|
1729
|
+
// config onto this baseUrl. Same reasoning as `zhipu-bigmodel` above.
|
|
1730
|
+
//
|
|
1731
|
+
// Models follow Z.AI's coding-plan list rather than the pay-as-you-go one. This endpoint is
|
|
1732
|
+
// the subscription product, and the reporter's `glm-5.2` is only on that side.
|
|
1733
|
+
{
|
|
1734
|
+
id: "zhipu-bigmodel-coding",
|
|
1735
|
+
label: "Zhipu AI — BigModel Coding Plan",
|
|
1736
|
+
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4",
|
|
1737
|
+
adapter: "openai-chat",
|
|
1738
|
+
authKind: "key",
|
|
1739
|
+
dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys",
|
|
1740
|
+
defaultModel: "glm-5.2",
|
|
1741
|
+
models: ["glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"],
|
|
1742
|
+
jawcodeBundle: "zai",
|
|
1743
|
+
modelContextWindows: { "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 },
|
|
1744
|
+
modelSuffixBracketStrip: true,
|
|
1745
|
+
noVisionModels: ZAI_GLM_52_MODELS,
|
|
1746
|
+
modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])),
|
|
1747
|
+
modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, true])),
|
|
1748
|
+
preserveReasoningContentModels: ZAI_GLM_52_MODELS,
|
|
1749
|
+
// No liveModels: the same reasoning as the pay-as-you-go row — an unverified live claim
|
|
1750
|
+
// yields an empty picker at runtime.
|
|
1751
|
+
note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)",
|
|
1752
|
+
},
|
|
1483
1753
|
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
|
|
1484
1754
|
{ id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
|
|
1485
1755
|
// SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not
|
|
@@ -1750,15 +2020,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1750
2020
|
preserveReasoningContentModels: KIMI_THINKING_MODELS,
|
|
1751
2021
|
},
|
|
1752
2022
|
{
|
|
1753
|
-
id: "opencode-zen",
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
2023
|
+
id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth",
|
|
2024
|
+
// Same opencode.ai/zen/v1 gateway as `opencode-free` (keyed tier): DeepSeek thinking mode
|
|
2025
|
+
// requires the assistant's original reasoning_content to be replayed on tool-call
|
|
2026
|
+
// continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek
|
|
2027
|
+
// reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other
|
|
2028
|
+
// Zen DeepSeek thinking models — never serialize a bare tool-call turn.
|
|
2029
|
+
modelReasoningEfforts: Object.fromEntries(
|
|
2030
|
+
[...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]),
|
|
2031
|
+
),
|
|
2032
|
+
modelReasoningEffortMap: Object.fromEntries(
|
|
2033
|
+
[...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]),
|
|
2034
|
+
),
|
|
2035
|
+
preserveReasoningContentModels: [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS],
|
|
2036
|
+
noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_THINKING_MODELS],
|
|
1762
2037
|
},
|
|
1763
2038
|
{ id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
|
|
1764
2039
|
{
|
|
@@ -1798,6 +2073,34 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1798
2073
|
models: ["mimo-auto"],
|
|
1799
2074
|
note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
|
|
1800
2075
|
},
|
|
2076
|
+
// Xiaomi MiMo paid token plan. Separate host and wire from both `xiaomi` (Anthropic) and
|
|
2077
|
+
// `mimo-free` (free tier, bespoke adapter), so it needs its own entry rather than a variant.
|
|
2078
|
+
//
|
|
2079
|
+
// Pinned to openai-chat deliberately (#1158). The endpoint answers the Responses wire for
|
|
2080
|
+
// plain turns, which is why users configuring it by hand pick `openai-responses` — MiMo
|
|
2081
|
+
// documents Responses support. But its gateway rejects `type: "custom"` tools with
|
|
2082
|
+
// `400 responses_feature_not_supported`, and `apply_patch` is a custom tool, so every agentic
|
|
2083
|
+
// turn fails while chat turns succeed. The Chat path lowers custom tools to `{input: string}`
|
|
2084
|
+
// functions and restores them as `custom_tool_call`, so the capability survives intact.
|
|
2085
|
+
// Stripping the tools instead would stop the 400 and disable the agent loop.
|
|
2086
|
+
{
|
|
2087
|
+
id: "mimo",
|
|
2088
|
+
label: "Xiaomi MiMo (token plan)",
|
|
2089
|
+
baseUrl: "https://token-plan-cn.xiaomimimo.com/v1",
|
|
2090
|
+
adapter: "openai-chat",
|
|
2091
|
+
authKind: "key",
|
|
2092
|
+
dashboardUrl: "https://xiaomimimo.com",
|
|
2093
|
+
defaultModel: "mimo-v2.5-pro",
|
|
2094
|
+
models: ["mimo-v2.5-pro", "mimo-v2.5"],
|
|
2095
|
+
// The gateway validates the ladder strictly and rejects anything above `high`.
|
|
2096
|
+
reasoningEfforts: ["low", "medium", "high"],
|
|
2097
|
+
reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" },
|
|
2098
|
+
// A user may already have hand-rolled a provider under this id against a different host;
|
|
2099
|
+
// without this, routedProviderConfig() would canonicalize their base URL onto ours and send
|
|
2100
|
+
// their key somewhere they did not choose.
|
|
2101
|
+
preserveCustomDestination: true,
|
|
2102
|
+
note: "Xiaomi MiMo paid token plan. Pinned to the Chat wire: the Responses endpoint rejects freeform (custom) tools such as apply_patch with 400 responses_feature_not_supported, so agentic turns fail there while plain turns succeed. Reasoning tiers above high are clamped.",
|
|
2103
|
+
},
|
|
1801
2104
|
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
1802
2105
|
{
|
|
1803
2106
|
// Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id}
|
package/src/responses/parser.ts
CHANGED
|
@@ -668,9 +668,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
668
668
|
...(data.tools as unknown[] ?? []),
|
|
669
669
|
...loadedToolSpecs,
|
|
670
670
|
]);
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
|
|
671
|
+
// Capture structured-output mode (Responses `text.format`): the format object rides
|
|
672
|
+
// options.textFormat for adapters whose wire has an equivalent (openai-chat response_format),
|
|
673
|
+
// while the `_structuredOutput` flag keeps the web-search sidecar rendering its tool_result
|
|
674
|
+
// as JSON rather than prose that could corrupt the model's schema-constrained answer.
|
|
675
|
+
const textFormat = parseTextFormat(data.text);
|
|
676
|
+
if (textFormat) options.textFormat = textFormat;
|
|
674
677
|
|
|
675
678
|
return {
|
|
676
679
|
modelId: data.model,
|
|
@@ -682,17 +685,30 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
682
685
|
...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
|
|
683
686
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
684
687
|
...(imageGen ? { _imageGeneration: imageGen } : {}),
|
|
685
|
-
...(
|
|
688
|
+
...(textFormat ? { _structuredOutput: true } : {}),
|
|
686
689
|
...(compactionRequest ? { _compactionRequest: true } : {}),
|
|
687
690
|
...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}),
|
|
688
691
|
};
|
|
689
692
|
}
|
|
690
693
|
|
|
691
|
-
/**
|
|
692
|
-
|
|
693
|
-
|
|
694
|
+
/**
|
|
695
|
+
* The Responses `text.format` object when it requests structured output (json_schema or
|
|
696
|
+
* json_object), undefined otherwise. Acceptance is identical to the boolean detector this
|
|
697
|
+
* replaces; unknown or malformed formats are ignored, never rejected, so the native
|
|
698
|
+
* passthrough keeps forwarding whatever the caller sent via `_rawBody`.
|
|
699
|
+
*/
|
|
700
|
+
function parseTextFormat(text: unknown): OcxRequestOptions["textFormat"] {
|
|
701
|
+
if (!isObj(text)) return undefined;
|
|
694
702
|
const format = (text as { format?: unknown }).format;
|
|
695
|
-
if (!isObj(format)) return
|
|
696
|
-
const
|
|
697
|
-
|
|
703
|
+
if (!isObj(format)) return undefined;
|
|
704
|
+
const f = format as { type?: unknown; name?: unknown; description?: unknown; schema?: unknown; strict?: unknown };
|
|
705
|
+
if (f.type === "json_object") return { type: "json_object" };
|
|
706
|
+
if (f.type !== "json_schema") return undefined;
|
|
707
|
+
return {
|
|
708
|
+
type: "json_schema",
|
|
709
|
+
...(typeof f.name === "string" ? { name: f.name } : {}),
|
|
710
|
+
...(typeof f.description === "string" ? { description: f.description } : {}),
|
|
711
|
+
...(isObj(f.schema) ? { schema: f.schema as Record<string, unknown> } : {}),
|
|
712
|
+
...(typeof f.strict === "boolean" ? { strict: f.strict } : {}),
|
|
713
|
+
};
|
|
698
714
|
}
|
|
@@ -44,6 +44,7 @@ const keyFor = (callId: string, scope: string | undefined): string =>
|
|
|
44
44
|
* id is never read again.
|
|
45
45
|
*/
|
|
46
46
|
export function rememberReasoningForCall(callId: string, text: string, scope?: string): void {
|
|
47
|
+
// Empty provider deltas are absence of new reasoning, not a request to erase a candidate.
|
|
47
48
|
if (!callId || typeof text !== "string" || text.length === 0) return;
|
|
48
49
|
const bytes = Buffer.byteLength(text, "utf8");
|
|
49
50
|
// A single entry larger than the whole budget would immediately evict itself.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { OcxRoutingProfileConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
/** Canonical public namespace reserved for routing-policy model ids. */
|
|
4
|
+
export const POLICY_NAMESPACE = "policy";
|
|
5
|
+
|
|
6
|
+
/** Public namespace prefixes claimed by slash-qualified routing-profile aliases. */
|
|
7
|
+
export function routingProfileAliasNamespacePrefixes(
|
|
8
|
+
config: { routingProfiles?: Record<string, OcxRoutingProfileConfig> },
|
|
9
|
+
): string[] {
|
|
10
|
+
return Object.values(config.routingProfiles ?? {}).flatMap((profile) => {
|
|
11
|
+
const alias = typeof profile?.alias === "string" ? profile.alias.trim() : "";
|
|
12
|
+
const slash = alias.indexOf("/");
|
|
13
|
+
return slash > 0 ? [alias.slice(0, slash)] : [];
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/routing/profile.ts
CHANGED
|
@@ -13,8 +13,9 @@ import type {
|
|
|
13
13
|
import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
|
|
14
14
|
import { listComboIds, resolveComboId } from "../combos";
|
|
15
15
|
import { hasOwnProvider } from "../config";
|
|
16
|
+
import { POLICY_NAMESPACE } from "./profile-namespace";
|
|
16
17
|
|
|
17
|
-
export
|
|
18
|
+
export { POLICY_NAMESPACE };
|
|
18
19
|
|
|
19
20
|
export const POLICY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
21
|
export const POLICY_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/;
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
export function isAllowedRequestOrigin(req: Request, config:
|
|
76
|
+
export function isAllowedRequestOrigin(req: Request, config: RequestPolicyView): boolean {
|
|
77
77
|
const origin = req.headers.get("Origin");
|
|
78
78
|
if (!isApiAuthRequired(config)) {
|
|
79
79
|
if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
|
|
@@ -82,7 +82,7 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
|
|
|
82
82
|
return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function isExtraAllowedOrigin(origin: string, cfg:
|
|
85
|
+
function isExtraAllowedOrigin(origin: string, cfg: RequestPolicyView): boolean {
|
|
86
86
|
if (!cfg.corsAllowOrigins?.length) return false;
|
|
87
87
|
const parsedOrigin = comparableOrigin(origin);
|
|
88
88
|
return cfg.corsAllowOrigins.some(allowed => {
|
|
@@ -136,7 +136,7 @@ export function browserSecurityHeaders(): Record<string, string> {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
export function corsHeaders(req?: Request, config?:
|
|
139
|
+
export function corsHeaders(req?: Request, config?: RequestPolicyView): Record<string, string> {
|
|
140
140
|
const origin = req?.headers.get("Origin");
|
|
141
141
|
const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
|
|
142
142
|
return {
|
|
@@ -160,7 +160,7 @@ export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record
|
|
|
160
160
|
return headers;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
export function withCors(response: Response, req: Request, config:
|
|
163
|
+
export function withCors(response: Response, req: Request, config: RequestPolicyView): Response {
|
|
164
164
|
const headers = new Headers(response.headers);
|
|
165
165
|
for (const [name, value] of Object.entries(corsHeaders(req, config))) {
|
|
166
166
|
headers.set(name, value);
|
|
@@ -184,14 +184,18 @@ export function withManagementCors(response: Response, req: Request, config: Ocx
|
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
export function jsonResponse(data: unknown, status = 200, req?: Request, config?:
|
|
187
|
+
export function jsonResponse(data: unknown, status = 200, req?: Request, config?: RequestPolicyView): Response {
|
|
188
188
|
return new Response(JSON.stringify(data), {
|
|
189
189
|
status,
|
|
190
190
|
headers: { "Content-Type": "application/json", ...corsHeaders(req, config) },
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
194
|
+
// The parameter is vestigial — the token has always come from the environment — but callers
|
|
195
|
+
// pass a config, so keep accepting one. Typed as `unknown` rather than `OcxConfig` so a narrow
|
|
196
|
+
// policy view can reach it too (#1102); widening to OcxConfig here would force every caller in
|
|
197
|
+
// the admission path back to the full config.
|
|
198
|
+
export function configuredApiAuthToken(_config?: unknown): string | undefined {
|
|
195
199
|
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
196
200
|
return token || undefined;
|
|
197
201
|
}
|
|
@@ -208,10 +212,37 @@ export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
|
208
212
|
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
209
213
|
}
|
|
210
214
|
|
|
211
|
-
export function isApiAuthRequired(config: OcxConfig): boolean {
|
|
215
|
+
export function isApiAuthRequired(config: Pick<OcxConfig, "hostname">): boolean {
|
|
212
216
|
return !isLoopbackHostname(config.hostname);
|
|
213
217
|
}
|
|
214
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The slice of config that decides admission and CORS, and nothing else (#1102).
|
|
221
|
+
*
|
|
222
|
+
* The unauthenticated loopback listener shares this process with the public one: same routing,
|
|
223
|
+
* same account pool, same drain. The only thing it must see differently is its own bind
|
|
224
|
+
* address, because `isApiAuthRequired` reads `hostname` and the shared config says "0.0.0.0".
|
|
225
|
+
*
|
|
226
|
+
* Two ways to express that were rejected. Passing the whole config with `hostname` rewritten
|
|
227
|
+
* and holding it for the listener's lifetime would go stale the moment the management API
|
|
228
|
+
* changes a setting. Adding an `allowUnauthenticated` parameter to the resolvers would create a
|
|
229
|
+
* callable admission bypass that the PUBLIC listener could also reach — the switch would exist
|
|
230
|
+
* on the wrong side of the boundary.
|
|
231
|
+
*
|
|
232
|
+
* So this type is deliberately narrow: it cannot masquerade as a business config, and a policy
|
|
233
|
+
* view that leaks into a routing path fails to typecheck rather than silently taking effect.
|
|
234
|
+
*/
|
|
235
|
+
export type RequestPolicyView = Pick<OcxConfig, "hostname" | "corsAllowOrigins" | "apiKeys">;
|
|
236
|
+
|
|
237
|
+
/** Derive the per-request policy view for a listener. Cheap enough to build per request. */
|
|
238
|
+
export function requestPolicyView(config: OcxConfig, bindHostname: string): RequestPolicyView {
|
|
239
|
+
return {
|
|
240
|
+
hostname: bindHostname,
|
|
241
|
+
...(config.corsAllowOrigins ? { corsAllowOrigins: config.corsAllowOrigins } : {}),
|
|
242
|
+
...(config.apiKeys ? { apiKeys: config.apiKeys } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
215
246
|
export function assertServerAuthConfig(config: OcxConfig): void {
|
|
216
247
|
const hasConfiguredDataCredential = !!configuredApiAuthToken(config)
|
|
217
248
|
|| (config.apiKeys ?? []).some(entry => !!entry.key.trim());
|
|
@@ -253,7 +284,7 @@ export type DataPlaneAdmission =
|
|
|
253
284
|
* discarded, which is what makes per-key attribution possible without touching
|
|
254
285
|
* the admission decision itself.
|
|
255
286
|
*/
|
|
256
|
-
export function resolveDataPlaneAdmissionSecret(token: string, config: OcxConfig): DataPlaneAdmission | null {
|
|
287
|
+
export function resolveDataPlaneAdmissionSecret(token: string, config: Pick<OcxConfig, "apiKeys">): DataPlaneAdmission | null {
|
|
257
288
|
const actual = token.trim();
|
|
258
289
|
if (!actual) return null;
|
|
259
290
|
if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment" };
|
|
@@ -341,7 +372,7 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
|
|
|
341
372
|
* Resolving form of `hasValidApiAuth`: identical header precedence, identical
|
|
342
373
|
* decision, but it names the admission instead of collapsing it to a boolean.
|
|
343
374
|
*/
|
|
344
|
-
export function resolveApiAuth(req: Request, config:
|
|
375
|
+
export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null {
|
|
345
376
|
// A loopback bind never reads a token at all, so there is no key to name.
|
|
346
377
|
if (!isApiAuthRequired(config)) return { kind: "loopback" };
|
|
347
378
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
@@ -352,11 +383,11 @@ export function resolveApiAuth(req: Request, config: OcxConfig): DataPlaneAdmiss
|
|
|
352
383
|
return resolveDataPlaneAdmissionSecret(actual, config);
|
|
353
384
|
}
|
|
354
385
|
|
|
355
|
-
export function hasValidApiAuth(req: Request, config:
|
|
386
|
+
export function hasValidApiAuth(req: Request, config: RequestPolicyView): boolean {
|
|
356
387
|
return resolveApiAuth(req, config) !== null;
|
|
357
388
|
}
|
|
358
389
|
|
|
359
|
-
export function requireApiAuth(req: Request, config:
|
|
390
|
+
export function requireApiAuth(req: Request, config: RequestPolicyView, _kind: "data-plane"): Response | null {
|
|
360
391
|
if (hasValidApiAuth(req, config)) return null;
|
|
361
392
|
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
362
393
|
}
|
|
@@ -366,7 +397,7 @@ export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-pla
|
|
|
366
397
|
* Codex Direct. Remote binds must use the dedicated proxy header so the two bearer
|
|
367
398
|
* domains can never be confused.
|
|
368
399
|
*/
|
|
369
|
-
export function resolveResponsesApiAuth(req: Request, config:
|
|
400
|
+
export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView): DataPlaneAdmission | null {
|
|
370
401
|
if (!isApiAuthRequired(config)) return { kind: "loopback" };
|
|
371
402
|
// Dedicated header ONLY. `Authorization` on these transports may belong to
|
|
372
403
|
// Codex Direct passthrough, and the two bearer domains must stay unconfusable.
|
|
@@ -375,7 +406,7 @@ export function resolveResponsesApiAuth(req: Request, config: OcxConfig): DataPl
|
|
|
375
406
|
return resolveDataPlaneAdmissionSecret(actual, config);
|
|
376
407
|
}
|
|
377
408
|
|
|
378
|
-
export function requireResponsesApiAuth(req: Request, config:
|
|
409
|
+
export function requireResponsesApiAuth(req: Request, config: RequestPolicyView): Response | null {
|
|
379
410
|
if (resolveResponsesApiAuth(req, config)) return null;
|
|
380
411
|
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
381
412
|
}
|