@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -17,11 +17,79 @@ import {
|
|
|
17
17
|
export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
|
|
18
18
|
export type MetadataModelIdNormalize = "case-insensitive";
|
|
19
19
|
|
|
20
|
+
export type ProviderModelDiscoveryScalar = string | number | boolean;
|
|
21
|
+
|
|
22
|
+
export type ProviderModelDiscoveryPredicate =
|
|
23
|
+
| {
|
|
24
|
+
path: readonly string[];
|
|
25
|
+
equalsAny: readonly ProviderModelDiscoveryScalar[];
|
|
26
|
+
caseInsensitive?: boolean;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
path: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* A string-valued upstream target uses substring matching; an array-valued target uses
|
|
32
|
+
* exact element matching. Use `equalsAny` when the string must match in full.
|
|
33
|
+
*/
|
|
34
|
+
containsAny: readonly ProviderModelDiscoveryScalar[];
|
|
35
|
+
caseInsensitive?: boolean;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
path: readonly string[];
|
|
39
|
+
/** Uses the same string-substring and array-element semantics as `containsAny`. */
|
|
40
|
+
containsAll: readonly ProviderModelDiscoveryScalar[];
|
|
41
|
+
caseInsensitive?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface ProviderModelDiscoveryFilter {
|
|
45
|
+
/** Every predicate must match. */
|
|
46
|
+
allOf?: readonly ProviderModelDiscoveryPredicate[];
|
|
47
|
+
/** At least one predicate must match. */
|
|
48
|
+
anyOf?: readonly ProviderModelDiscoveryPredicate[];
|
|
49
|
+
/** No predicate may match. */
|
|
50
|
+
noneOf?: readonly ProviderModelDiscoveryPredicate[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ProviderModelDiscoverySharedSpec {
|
|
54
|
+
/** Query parameters applied to the resolved discovery URL. */
|
|
55
|
+
query?: Readonly<Record<string, string>>;
|
|
56
|
+
/** Declarative eligibility rules evaluated against each untrusted model row. */
|
|
57
|
+
filter?: ProviderModelDiscoveryFilter;
|
|
58
|
+
/** Optional lower byte ceiling; the process-wide hard ceiling still wins. */
|
|
59
|
+
maxResponseBytes?: number;
|
|
60
|
+
/** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */
|
|
61
|
+
maxModels?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ProviderModelDiscoveryLocation =
|
|
65
|
+
| {
|
|
66
|
+
/** Registry-owned absolute endpoint. Mutually exclusive with `path`. */
|
|
67
|
+
url: string;
|
|
68
|
+
path?: never;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
/** Resource path relative to baseUrl; query strings and fragments are disallowed. */
|
|
72
|
+
path: string;
|
|
73
|
+
url?: never;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
/** Keep the adapter-derived default discovery endpoint. */
|
|
77
|
+
url?: never;
|
|
78
|
+
path?: never;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Trusted live-model discovery policy. This metadata is registry-only: it must never be copied
|
|
83
|
+
* into config.json, where a same-named custom provider could otherwise redirect a stored key.
|
|
84
|
+
*/
|
|
85
|
+
export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation;
|
|
86
|
+
|
|
20
87
|
export interface ProviderRegistryEntry {
|
|
21
88
|
id: string;
|
|
22
89
|
label: string;
|
|
23
90
|
adapter: string;
|
|
24
91
|
baseUrl: string;
|
|
92
|
+
apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
|
|
25
93
|
authKind: ProviderAuthKind;
|
|
26
94
|
codexAccountMode?: CodexAccountMode;
|
|
27
95
|
/** OAuth preset may explicitly honor a persisted API-key billing mode. */
|
|
@@ -34,6 +102,11 @@ export interface ProviderRegistryEntry {
|
|
|
34
102
|
*/
|
|
35
103
|
freeTier?: boolean;
|
|
36
104
|
allowBaseUrlOverride?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Do not claim an existing same-named key provider whose fixed destination differs from this
|
|
107
|
+
* preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted.
|
|
108
|
+
*/
|
|
109
|
+
preserveCustomDestination?: boolean;
|
|
37
110
|
/**
|
|
38
111
|
* Optional endpoint picker for providers with multiple official hosts
|
|
39
112
|
* (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride`
|
|
@@ -50,6 +123,7 @@ export interface ProviderRegistryEntry {
|
|
|
50
123
|
defaultModel?: string;
|
|
51
124
|
models?: string[];
|
|
52
125
|
liveModels?: boolean;
|
|
126
|
+
modelDiscovery?: ProviderModelDiscoverySpec;
|
|
53
127
|
contextWindow?: number;
|
|
54
128
|
modelContextWindows?: Record<string, number>;
|
|
55
129
|
modelInputModalities?: Record<string, string[]>;
|
|
@@ -88,7 +162,7 @@ export interface ProviderRegistryEntry {
|
|
|
88
162
|
|
|
89
163
|
export type ProviderConfigSeed = Pick<
|
|
90
164
|
OcxProviderConfig,
|
|
91
|
-
"adapter" | "baseUrl" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
|
|
165
|
+
"adapter" | "baseUrl" | "apiKeyTransport" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
|
|
92
166
|
| "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
|
|
93
167
|
| "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
|
|
94
168
|
| "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
|
|
@@ -383,6 +457,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
383
457
|
modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
|
|
384
458
|
modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
|
|
385
459
|
modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
|
|
460
|
+
// Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium`
|
|
461
|
+
// rung — so applyReasoningLevels' medium->high->first fallback would settle the catalog
|
|
462
|
+
// default on `high`, the picker would send `high` explicitly, and the request builder's
|
|
463
|
+
// no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3
|
|
464
|
+
// routes (kimi, kimi-code, opencode-go).
|
|
465
|
+
modelDefaultReasoningEfforts: { "kimi-k3": "max" },
|
|
386
466
|
// Cursor's wire protocol never forwards image parts (request-builder emits an unsupported-
|
|
387
467
|
// content marker), so the vision sidecar covers ALL cursor models regardless of what the
|
|
388
468
|
// upstream model could natively do. Live-discovered models outside the static list fall back
|
|
@@ -480,6 +560,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
480
560
|
baseUrl: "https://api.kimi.com/coding/v1",
|
|
481
561
|
authKind: "oauth",
|
|
482
562
|
modelSuffixBracketStrip: true,
|
|
563
|
+
// Kimi Code Plan documents a stable session/task prompt_cache_key as required to improve
|
|
564
|
+
// cache hit rates.
|
|
565
|
+
// The chat adapter only forwards a key already on the internal request (Codex's session key,
|
|
566
|
+
// or the one the Claude /v1/messages inbound derives); the adapter itself never invents one.
|
|
567
|
+
// Evidence: https://platform.kimi.com/docs/api/chat
|
|
568
|
+
promptCacheKey: true,
|
|
483
569
|
featured: true,
|
|
484
570
|
oauthId: "kimi",
|
|
485
571
|
jawcodeBundle: "moonshot",
|
|
@@ -506,7 +592,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
506
592
|
baseUrl: "https://runtime.us-east-1.kiro.dev",
|
|
507
593
|
authKind: "oauth",
|
|
508
594
|
oauthId: "kiro",
|
|
509
|
-
note: "Import-first: reuses your installed Kiro CLI
|
|
595
|
+
note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.",
|
|
510
596
|
models: KIRO_MODELS,
|
|
511
597
|
defaultModel: "kiro-auto",
|
|
512
598
|
// Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static
|
|
@@ -978,7 +1064,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
978
1064
|
authKind: "key",
|
|
979
1065
|
dashboardUrl: "https://ollama.com/settings/keys",
|
|
980
1066
|
// Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
|
|
981
|
-
// Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md.
|
|
982
1067
|
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
|
|
983
1068
|
defaultModel: "glm-5.2",
|
|
984
1069
|
noVisionModels: [
|
|
@@ -1019,6 +1104,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1019
1104
|
id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key",
|
|
1020
1105
|
dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code",
|
|
1021
1106
|
modelSuffixBracketStrip: true,
|
|
1107
|
+
// API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth.
|
|
1108
|
+
promptCacheKey: true,
|
|
1022
1109
|
models: KIMI_CODING_MODELS,
|
|
1023
1110
|
modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS,
|
|
1024
1111
|
modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES,
|
|
@@ -1113,6 +1200,41 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un
|
|
|
1113
1200
|
return PROVIDER_REGISTRY.find(entry => entry.id === id);
|
|
1114
1201
|
}
|
|
1115
1202
|
|
|
1203
|
+
function normalizedProviderEndpoint(value: string): string {
|
|
1204
|
+
const trimmed = value.trim();
|
|
1205
|
+
try {
|
|
1206
|
+
const parsed = new URL(trimmed);
|
|
1207
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
|
|
1208
|
+
return parsed.toString().replace(/\/$/, "");
|
|
1209
|
+
} catch {
|
|
1210
|
+
return trimmed.replace(/\/+$/, "");
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Whether registry transport defaults own this configured row.
|
|
1216
|
+
*
|
|
1217
|
+
* OAuth/forward providers stay pinned because their credentials must never be sent to an
|
|
1218
|
+
* arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new
|
|
1219
|
+
* preset can opt into collision preservation, in which case its fixed endpoint owns only rows
|
|
1220
|
+
* that still match that destination.
|
|
1221
|
+
*/
|
|
1222
|
+
export function providerMatchesRegistryTransport(
|
|
1223
|
+
id: string,
|
|
1224
|
+
provider: Pick<OcxProviderConfig, "baseUrl" | "adapter"> & Partial<Pick<OcxProviderConfig, "authMode">>,
|
|
1225
|
+
): boolean {
|
|
1226
|
+
const entry = getProviderRegistryEntry(id);
|
|
1227
|
+
if (!entry) return false;
|
|
1228
|
+
if (entry.authKind !== "key" || entry.preserveCustomDestination !== true) return true;
|
|
1229
|
+
// The opt-in is intentionally limited to fixed key destinations. Fail closed if a future
|
|
1230
|
+
// registry edit combines it with an override/template despite the registry parity tests.
|
|
1231
|
+
if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false;
|
|
1232
|
+
if (typeof provider.baseUrl !== "string") return false;
|
|
1233
|
+
if (provider.adapter !== entry.adapter) return false;
|
|
1234
|
+
if (provider.authMode !== undefined && provider.authMode !== "key") return false;
|
|
1235
|
+
return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1116
1238
|
/**
|
|
1117
1239
|
* Effective Codex account mode for a provider. For canonical `openai`, a valid persisted
|
|
1118
1240
|
* `codexAccountMode` on the provider config wins and a missing/invalid value defaults to
|
package/src/responses/parser.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { compactionItemToText } from "./compaction";
|
|
|
16
16
|
import { previousResponseReplayPrefixLength } from "./state";
|
|
17
17
|
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
18
18
|
import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
|
|
19
|
+
import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
|
|
19
20
|
|
|
20
21
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
21
22
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -107,6 +108,10 @@ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
|
|
|
107
108
|
if ((t === "function" || t === "custom") && "name" in value) {
|
|
108
109
|
return { name: (value as { name: string }).name };
|
|
109
110
|
}
|
|
111
|
+
// Hosted image tool types (with or without a name) map to the synthetic image_gen wire name.
|
|
112
|
+
if (t === "image_generation" || t === "image_gen") {
|
|
113
|
+
return { name: IMAGE_GEN_TOOL_NAME };
|
|
114
|
+
}
|
|
110
115
|
if (t === "allowed_tools" && Array.isArray(value.tools)) {
|
|
111
116
|
const names = value.tools
|
|
112
117
|
.map(allowedToolName)
|
|
@@ -124,6 +129,7 @@ function allowedToolName(tool: unknown): string | undefined {
|
|
|
124
129
|
if (!isObj(tool)) return undefined;
|
|
125
130
|
if (typeof tool.name === "string" && tool.name.length > 0) return tool.name;
|
|
126
131
|
if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME;
|
|
132
|
+
if (tool.type === "image_generation" || tool.type === "image_gen") return IMAGE_GEN_TOOL_NAME;
|
|
127
133
|
if (tool.type === "tool_search") return "tool_search";
|
|
128
134
|
return undefined;
|
|
129
135
|
}
|
|
@@ -612,6 +618,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
612
618
|
// gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
|
|
613
619
|
// re-injects a synthetic function tool only when it will actually handle the call.
|
|
614
620
|
const webSearch = extractHostedWebSearch(data.tools as unknown[] | undefined);
|
|
621
|
+
const imageGen = extractHostedImageGeneration([
|
|
622
|
+
...(data.tools as unknown[] ?? []),
|
|
623
|
+
...loadedToolSpecs,
|
|
624
|
+
]);
|
|
615
625
|
// Detect structured-output mode (Responses `text.format`) so the web-search sidecar can render its
|
|
616
626
|
// tool_result as JSON rather than prose that could corrupt the model's schema-constrained answer.
|
|
617
627
|
const structuredOutput = detectStructuredOutput(data.text);
|
|
@@ -625,6 +635,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
625
635
|
_rawBody: body,
|
|
626
636
|
...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
|
|
627
637
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
638
|
+
...(imageGen ? { _imageGeneration: imageGen } : {}),
|
|
628
639
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
|
629
640
|
...(compactionRequest ? { _compactionRequest: true } : {}),
|
|
630
641
|
...(contextCompactionBoundary ? { _contextCompactionBoundary: true } : {}),
|
package/src/responses/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { atomicWriteFileAsync, getConfigDir } from "../config";
|
|
4
4
|
import type { OcxProviderContinuationState } from "../types";
|
|
5
5
|
|
|
6
6
|
const MAX_STORED_RESPONSES = 1_000;
|
|
@@ -83,6 +83,8 @@ const replayedInputPrefixLengths = new WeakMap<object, number>();
|
|
|
83
83
|
let loaded = false;
|
|
84
84
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
85
|
let pendingPersistPath: string | null = null;
|
|
86
|
+
/** Single-flight gate: overlapping response-state writes serialize (#612). */
|
|
87
|
+
let persistGate: Promise<void> = Promise.resolve();
|
|
86
88
|
|
|
87
89
|
function now(): number {
|
|
88
90
|
return Date.now();
|
|
@@ -247,12 +249,18 @@ function ensureLoaded(): void {
|
|
|
247
249
|
}
|
|
248
250
|
}
|
|
249
251
|
|
|
250
|
-
function persistNow(path: string): void {
|
|
252
|
+
async function persistNow(path: string): Promise<void> {
|
|
251
253
|
if (persistTimer) {
|
|
252
254
|
clearTimeout(persistTimer);
|
|
253
255
|
persistTimer = null;
|
|
254
256
|
}
|
|
255
257
|
pendingPersistPath = null;
|
|
258
|
+
|
|
259
|
+
// Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
|
|
260
|
+
const previous = persistGate;
|
|
261
|
+
let release!: () => void;
|
|
262
|
+
persistGate = new Promise<void>(resolve => { release = resolve; });
|
|
263
|
+
await previous;
|
|
256
264
|
try {
|
|
257
265
|
const entries: [string, StoredResponseState][] = [];
|
|
258
266
|
let total = 0;
|
|
@@ -273,9 +281,11 @@ function persistNow(path: string): void {
|
|
|
273
281
|
// mkdirSync's mode only applies on creation — re-harden an existing config dir so the
|
|
274
282
|
// conversation-content snapshot never lands in a group/world-readable directory.
|
|
275
283
|
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
276
|
-
|
|
284
|
+
await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
|
|
277
285
|
} catch {
|
|
278
286
|
/* best-effort: disk trouble must never affect request handling */
|
|
287
|
+
} finally {
|
|
288
|
+
release();
|
|
279
289
|
}
|
|
280
290
|
}
|
|
281
291
|
|
|
@@ -285,15 +295,18 @@ function schedulePersist(): void {
|
|
|
285
295
|
// debounce fires, and a late write must land in the home that owned the recorded state.
|
|
286
296
|
pendingPersistPath = snapshotPath();
|
|
287
297
|
const path = pendingPersistPath;
|
|
288
|
-
persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
|
|
298
|
+
persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
|
|
289
299
|
(persistTimer as { unref?: () => void }).unref?.();
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
|
|
293
|
-
export function flushResponseState(): void {
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
303
|
+
export async function flushResponseState(): Promise<void> {
|
|
304
|
+
if (persistTimer) {
|
|
305
|
+
await persistNow(pendingPersistPath ?? snapshotPath());
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// No pending timer: still await any in-flight write so shutdown does not race (#612).
|
|
309
|
+
await persistGate;
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
function inputItems(input: unknown): unknown[] {
|
|
@@ -448,6 +461,7 @@ export function clearResponseStateMemoryForTests(): void {
|
|
|
448
461
|
clearTimeout(persistTimer);
|
|
449
462
|
persistTimer = null;
|
|
450
463
|
}
|
|
464
|
+
pendingPersistPath = null;
|
|
451
465
|
states.clear();
|
|
452
466
|
storedResponseBytes = 0;
|
|
453
467
|
loaded = false;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Collect top-level and Responses Lite tool containers without altering their order. */
|
|
6
|
+
export function collectResponsesToolGroups(body: unknown): unknown[][] {
|
|
7
|
+
if (!isPlainObject(body)) return [];
|
|
8
|
+
|
|
9
|
+
const groups: unknown[][] = [];
|
|
10
|
+
if (Array.isArray(body.tools)) groups.push(body.tools);
|
|
11
|
+
if (!Array.isArray(body.input)) return groups;
|
|
12
|
+
|
|
13
|
+
for (const item of body.input) {
|
|
14
|
+
if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
15
|
+
groups.push(item.tools);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return groups;
|
|
19
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } fro
|
|
|
3
3
|
import { hasOwnProvider, resolveEnvValue } from "./config";
|
|
4
4
|
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
5
5
|
import { redactSecretString, redactUrlForLog } from "./lib/redact";
|
|
6
|
-
import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
|
|
6
|
+
import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry";
|
|
7
7
|
import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
8
8
|
import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
|
|
9
9
|
import { getStaleCached } from "./codex/model-cache";
|
|
@@ -40,7 +40,9 @@ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string
|
|
|
40
40
|
export function knownModelIdsForProvider(provName: string, prov: OcxProviderConfig): string[] {
|
|
41
41
|
const ids = new Set<string>();
|
|
42
42
|
for (const id of prov.models ?? []) ids.add(id);
|
|
43
|
-
const registry =
|
|
43
|
+
const registry = providerMatchesRegistryTransport(provName, prov)
|
|
44
|
+
? PROVIDER_REGISTRY.find(entry => entry.id === provName)
|
|
45
|
+
: undefined;
|
|
44
46
|
for (const id of registry?.models ?? []) ids.add(id);
|
|
45
47
|
// Registry model-keyed hint maps double as known native ids (e.g. NVIDIA carries no
|
|
46
48
|
// static models list but names `moonshotai/kimi-k2.6` in its effort/window maps).
|
|
@@ -185,15 +187,22 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec
|
|
|
185
187
|
);
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
function usableResolvedApiKey(apiKey: string | undefined): string | undefined {
|
|
191
|
+
const resolved = resolveEnvValue(apiKey);
|
|
192
|
+
return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
188
195
|
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
189
196
|
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
190
|
-
if (!registryEntry) {
|
|
197
|
+
if (!registryEntry || !providerMatchesRegistryTransport(providerName, provider)) {
|
|
191
198
|
assertProviderDestinationAllowed(providerName, provider);
|
|
192
|
-
return { ...provider, apiKey:
|
|
199
|
+
return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) };
|
|
193
200
|
}
|
|
201
|
+
const resolvedApiKey = usableResolvedApiKey(provider.apiKey);
|
|
194
202
|
const explicitKeyOverride = registryEntry.authKind === "oauth"
|
|
195
203
|
&& registryEntry.allowKeyAuthOverride === true
|
|
196
|
-
&& provider.authMode === "key"
|
|
204
|
+
&& provider.authMode === "key"
|
|
205
|
+
&& resolvedApiKey !== undefined;
|
|
197
206
|
const canonicalAuthMode = explicitKeyOverride
|
|
198
207
|
? "key"
|
|
199
208
|
: registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
|
|
@@ -239,7 +248,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
239
248
|
adapter: registryEntry.adapter,
|
|
240
249
|
baseUrl,
|
|
241
250
|
authMode: canonicalAuthMode,
|
|
242
|
-
apiKey:
|
|
251
|
+
apiKey: resolvedApiKey,
|
|
243
252
|
// Backfill the Google wire mode + Vertex project/location from the registry when the user
|
|
244
253
|
// config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
|
|
245
254
|
// through the correct branch (CCA/Vertex) instead of falling back to AI Studio.
|
|
@@ -286,7 +295,10 @@ function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][]
|
|
|
286
295
|
|
|
287
296
|
export class NoEnabledOpenAiProviderError extends Error {
|
|
288
297
|
constructor(modelId: string) {
|
|
289
|
-
super(
|
|
298
|
+
super(
|
|
299
|
+
`Model ${modelId} requires the canonical openai provider. `
|
|
300
|
+
+ `Run: ocx provider add openai && ocx sync && ocx restart`,
|
|
301
|
+
);
|
|
290
302
|
this.name = "NoEnabledOpenAiProviderError";
|
|
291
303
|
}
|
|
292
304
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { formatErrorResponse } from "../bridge";
|
|
3
3
|
import {
|
|
4
|
+
apiKeyTransportConfigError,
|
|
4
5
|
booleanRecordConfigError,
|
|
5
6
|
modelAdapterRecordConfigError,
|
|
6
7
|
codexAutoStartEnabled,
|
|
@@ -11,13 +12,14 @@ import {
|
|
|
11
12
|
reasoningSummaryDeliveryRecordConfigError,
|
|
12
13
|
} from "../config";
|
|
13
14
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
14
|
-
import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
|
|
15
|
+
import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry";
|
|
15
16
|
import { providerConfigSeed } from "../providers/derive";
|
|
16
17
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
17
18
|
import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
|
|
18
19
|
|
|
19
20
|
let _corsOrigin = "http://localhost:10100";
|
|
20
21
|
export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
|
|
22
|
+
/** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */
|
|
21
23
|
export function configuredPort(): string {
|
|
22
24
|
try { return new URL(_corsOrigin).port; } catch { return "10100"; }
|
|
23
25
|
}
|
|
@@ -35,8 +37,18 @@ export function parseHttpHost(value: string | null): { hostname: string; port: s
|
|
|
35
37
|
export function isLoopbackRequestHost(value: string | null): boolean {
|
|
36
38
|
const parsed = parseHttpHost(value);
|
|
37
39
|
if (!parsed) return true;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
// Loopback is a trust boundary by hostname, not by port. `ssh -L 20100:localhost:10100`
|
|
41
|
+
// legitimately arrives as `Host: localhost:20100`, and refusing it took the whole /v1/*
|
|
42
|
+
// data plane down with it, not just CORS. The sibling isLoopbackOriginValue() dropped its
|
|
43
|
+
// own port check for the same reason in e4e06125b ("same-trust-boundary"). Port equality
|
|
44
|
+
// was never the rebinding defense: a rebinding browser connects to the real port and sends
|
|
45
|
+
// it verbatim, so the hostname check below is what rejected it then and now.
|
|
46
|
+
//
|
|
47
|
+
// Scope of that guarantee: it holds for Hosts `parseHttpHost` can parse. An unparseable
|
|
48
|
+
// Host still returns true above — pre-existing behavior, not browser-reachable (a browser
|
|
49
|
+
// composes Host from its own connection), and pinned by a characterization test in
|
|
50
|
+
// tests/server-loopback-host-gate.test.ts. Tightening it is separate work.
|
|
51
|
+
return isLoopbackHostname(parsed.hostname);
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
export function isLoopbackOriginValue(value: string): boolean {
|
|
@@ -76,6 +88,34 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
|
|
|
76
88
|
return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
|
|
77
89
|
}
|
|
78
90
|
|
|
91
|
+
export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
|
|
92
|
+
const host = req.headers.get("Host");
|
|
93
|
+
const parsedHost = parseHttpHost(host);
|
|
94
|
+
if (!host || !parsedHost) return null;
|
|
95
|
+
if (!isApiAuthRequired(config) && !isLoopbackHostname(parsedHost.hostname)) return null;
|
|
96
|
+
try {
|
|
97
|
+
const protocol = new URL(req.url).protocol;
|
|
98
|
+
if (protocol !== "http:" && protocol !== "https:") return null;
|
|
99
|
+
return new URL(`${protocol}//${host}`).origin;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isAllowedManagementOrigin(req: Request, config: OcxConfig): boolean {
|
|
106
|
+
const requestOrigin = managementRequestOrigin(req, config);
|
|
107
|
+
if (!requestOrigin) return false;
|
|
108
|
+
const origin = req.headers.get("Origin");
|
|
109
|
+
return !origin || origin === requestOrigin;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function browserSecurityHeaders(): Record<string, string> {
|
|
113
|
+
return {
|
|
114
|
+
"X-Frame-Options": "DENY",
|
|
115
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
|
|
80
120
|
const origin = req?.headers.get("Origin");
|
|
81
121
|
const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
|
|
@@ -87,9 +127,19 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
|
|
|
87
127
|
// block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
|
|
88
128
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
|
|
89
129
|
"Vary": "Origin",
|
|
130
|
+
...browserSecurityHeaders(),
|
|
90
131
|
};
|
|
91
132
|
}
|
|
92
133
|
|
|
134
|
+
export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
|
|
135
|
+
const headers = corsHeaders();
|
|
136
|
+
const origin = req?.headers.get("Origin");
|
|
137
|
+
if (origin && req && config && isAllowedManagementOrigin(req, config)) {
|
|
138
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
139
|
+
}
|
|
140
|
+
return headers;
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
export function withCors(response: Response, req: Request, config: OcxConfig): Response {
|
|
94
144
|
const headers = new Headers(response.headers);
|
|
95
145
|
for (const [name, value] of Object.entries(corsHeaders(req, config))) {
|
|
@@ -102,6 +152,18 @@ export function withCors(response: Response, req: Request, config: OcxConfig): R
|
|
|
102
152
|
});
|
|
103
153
|
}
|
|
104
154
|
|
|
155
|
+
export function withManagementCors(response: Response, req: Request, config: OcxConfig): Response {
|
|
156
|
+
const headers = new Headers(response.headers);
|
|
157
|
+
for (const [name, value] of Object.entries(managementCorsHeaders(req, config))) {
|
|
158
|
+
headers.set(name, value);
|
|
159
|
+
}
|
|
160
|
+
return new Response(response.body, {
|
|
161
|
+
status: response.status,
|
|
162
|
+
statusText: response.statusText,
|
|
163
|
+
headers,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
105
167
|
export function jsonResponse(data: unknown, status = 200, req?: Request, config?: OcxConfig): Response {
|
|
106
168
|
return new Response(JSON.stringify(data), {
|
|
107
169
|
status,
|
|
@@ -114,8 +176,15 @@ export function configuredApiAuthToken(_config: OcxConfig): string | undefined {
|
|
|
114
176
|
return token || undefined;
|
|
115
177
|
}
|
|
116
178
|
|
|
179
|
+
export function configuredAdminAuthToken(): string | undefined {
|
|
180
|
+
const token = process.env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim();
|
|
181
|
+
return token || undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
117
184
|
export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
118
|
-
|
|
185
|
+
// A fully-qualified "localhost." is the same host as "localhost": curl and some clients
|
|
186
|
+
// send the trailing dot verbatim, and refusing it 403s a legitimate loopback caller.
|
|
187
|
+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");
|
|
119
188
|
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
120
189
|
}
|
|
121
190
|
|
|
@@ -124,31 +193,48 @@ export function isApiAuthRequired(config: OcxConfig): boolean {
|
|
|
124
193
|
}
|
|
125
194
|
|
|
126
195
|
export function assertServerAuthConfig(config: OcxConfig): void {
|
|
127
|
-
|
|
128
|
-
|
|
196
|
+
const hasConfiguredDataCredential = !!configuredApiAuthToken(config)
|
|
197
|
+
|| (config.apiKeys ?? []).some(entry => !!entry.key.trim());
|
|
198
|
+
if (isApiAuthRequired(config) && !hasConfiguredDataCredential) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
"A data-plane credential (OPENCODEX_API_AUTH_TOKEN or config.apiKeys) is required when binding opencodex to a non-loopback hostname",
|
|
201
|
+
);
|
|
129
202
|
}
|
|
130
203
|
}
|
|
131
204
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const actual = token.trim();
|
|
135
|
-
if (!actual) return false;
|
|
205
|
+
function secretEquals(actual: string, expected: string | undefined): boolean {
|
|
206
|
+
if (!expected) return false;
|
|
136
207
|
const enc = new TextEncoder();
|
|
137
208
|
const actualBytes = enc.encode(actual);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
209
|
+
const expectedBytes = enc.encode(expected);
|
|
210
|
+
return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether `token` is a data-plane admission secret. */
|
|
214
|
+
export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
|
|
215
|
+
const actual = token.trim();
|
|
216
|
+
if (!actual) return false;
|
|
217
|
+
if (secretEquals(actual, configuredApiAuthToken(config))) return true;
|
|
145
218
|
for (const k of config.apiKeys ?? []) {
|
|
146
|
-
|
|
147
|
-
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
|
|
219
|
+
if (secretEquals(actual, k.key)) return true;
|
|
148
220
|
}
|
|
149
221
|
return false;
|
|
150
222
|
}
|
|
151
223
|
|
|
224
|
+
/** Whether `token` is the environment-provided management secret. */
|
|
225
|
+
export function isManagementAdmissionSecret(token: string): boolean {
|
|
226
|
+
const actual = token.trim();
|
|
227
|
+
return !!actual && secretEquals(actual, configuredAdminAuthToken());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Whether `token` is one of the proxy's own admission secrets and must never reach an upstream. */
|
|
231
|
+
export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
|
|
232
|
+
const actual = token.trim();
|
|
233
|
+
if (!actual) return false;
|
|
234
|
+
if (/^ocx_(?:data|admin|session)_/.test(actual) || /^ocx_[0-9a-f]{40}$/.test(actual)) return true;
|
|
235
|
+
return isDataPlaneAdmissionSecret(actual, config) || isManagementAdmissionSecret(actual);
|
|
236
|
+
}
|
|
237
|
+
|
|
152
238
|
export class ForwardAdmissionCredentialError extends Error {
|
|
153
239
|
constructor() {
|
|
154
240
|
super("OpenCodex admission credentials cannot be forwarded upstream");
|
|
@@ -168,12 +254,11 @@ export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
|
|
|
168
254
|
// Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
|
|
169
255
|
|| req.headers.get("x-api-key")?.trim();
|
|
170
256
|
if (!actual) return false;
|
|
171
|
-
return
|
|
257
|
+
return isDataPlaneAdmissionSecret(actual, config);
|
|
172
258
|
}
|
|
173
259
|
|
|
174
|
-
export function requireApiAuth(req: Request, config: OcxConfig,
|
|
260
|
+
export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-plane"): Response | null {
|
|
175
261
|
if (hasValidApiAuth(req, config)) return null;
|
|
176
|
-
if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
|
|
177
262
|
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
178
263
|
}
|
|
179
264
|
|
|
@@ -185,7 +270,7 @@ export function requireApiAuth(req: Request, config: OcxConfig, kind: "managemen
|
|
|
185
270
|
export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
|
|
186
271
|
if (!isApiAuthRequired(config)) return null;
|
|
187
272
|
const actual = req.headers.get("x-opencodex-api-key")?.trim();
|
|
188
|
-
if (actual &&
|
|
273
|
+
if (actual && isDataPlaneAdmissionSecret(actual, config)) return null;
|
|
189
274
|
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
190
275
|
}
|
|
191
276
|
|
|
@@ -232,6 +317,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
232
317
|
if (destinationError) return `provider ${name} ${destinationError}`;
|
|
233
318
|
const headersError = providerHeadersConfigError(typed.headers);
|
|
234
319
|
if (headersError) return `provider ${name} ${headersError}`;
|
|
320
|
+
const apiKeyTransportError = apiKeyTransportConfigError(typed);
|
|
321
|
+
if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`;
|
|
235
322
|
const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
|
|
236
323
|
if (maxInputError) return `provider ${name} ${maxInputError}`;
|
|
237
324
|
const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
|
|
@@ -307,6 +394,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
307
394
|
"disabled",
|
|
308
395
|
"allowPrivateNetwork",
|
|
309
396
|
"authMode",
|
|
397
|
+
"apiKeyTransport",
|
|
310
398
|
"keyOptional",
|
|
311
399
|
"freeTier",
|
|
312
400
|
"liveModels",
|
|
@@ -330,7 +418,9 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
330
418
|
] as const) {
|
|
331
419
|
copyIfDefined(dto, provider, key);
|
|
332
420
|
}
|
|
333
|
-
const registryNote =
|
|
421
|
+
const registryNote = providerMatchesRegistryTransport(name, provider)
|
|
422
|
+
? getProviderRegistryEntry(name)?.note
|
|
423
|
+
: undefined;
|
|
334
424
|
if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
|
|
335
425
|
const codexAccountMode = providerCodexAccountMode(name, provider);
|
|
336
426
|
if (codexAccountMode) dto.codexAccountMode = codexAccountMode;
|