@bitkyc08/opencodex 2.90.0 → 2.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
- package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +205 -97
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/quota/antigravity.ts +2 -8
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +2 -2
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +72 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +9 -1
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The served-by response header, the Home instance id it carries, and whether this build sends it.
|
|
3
|
+
*
|
|
4
|
+
* Every `GET /v1/models` response the server answers itself names the serving Home in
|
|
5
|
+
* `x-opencodex-served-by`, and `GET /healthz` publishes the same value as `instanceId`, so a peer
|
|
6
|
+
* (GOAT's parity check P4) can prove which Home answered without reading logs. A Remote Link Child
|
|
7
|
+
* never answers `/v1/models` itself: its relay copies the Home's response headers, so the value a
|
|
8
|
+
* Child returns is the Home's. A Hub-mode client's Codex calls the Hub's data listener directly, so
|
|
9
|
+
* the Hub stamps its own response.
|
|
10
|
+
*
|
|
11
|
+
* The instance id is the install's `homeId` from `src/quota-pool/identity.ts`: a random
|
|
12
|
+
* `home_<32 hex>` minted once per install, persisted owner-only, and stable across restarts. It is
|
|
13
|
+
* never a hostname and is not derived from one. Until the identity exists (it is created in the
|
|
14
|
+
* background after the listener starts) or when its file is unreadable, the header and the
|
|
15
|
+
* `/healthz` field are omitted; an empty or malformed value is never sent.
|
|
16
|
+
*
|
|
17
|
+
* `SERVED_BY_HEADER_EMITTED` tells `/readyz` to advertise `served-by-header`
|
|
18
|
+
* (`src/remote/features.ts`); `tests/server/readyz-features.test.ts` fails if the advertisement
|
|
19
|
+
* and live responses disagree.
|
|
20
|
+
*/
|
|
21
|
+
import { readQuotaPoolIdentity } from "../quota-pool/identity";
|
|
22
|
+
|
|
23
|
+
export const SERVED_BY_HEADER = "x-opencodex-served-by";
|
|
24
|
+
|
|
25
|
+
/** Whether responses from this build carry {@link SERVED_BY_HEADER}. */
|
|
26
|
+
export const SERVED_BY_HEADER_EMITTED: boolean = true;
|
|
27
|
+
|
|
28
|
+
const INSTANCE_ID = /^home_[0-9a-f]{32}$/;
|
|
29
|
+
|
|
30
|
+
/** This Home's opaque instance id, or undefined while the install identity is unavailable. */
|
|
31
|
+
export function homeInstanceId(): string | undefined {
|
|
32
|
+
let homeId: unknown;
|
|
33
|
+
try { homeId = readQuotaPoolIdentity()?.homeId; } catch { return undefined; }
|
|
34
|
+
return typeof homeId === "string" && INSTANCE_ID.test(homeId) ? homeId : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Stamp {@link SERVED_BY_HEADER} with this Home's instance id; leaves the response alone without one. */
|
|
38
|
+
export function withServedBy(response: Response, instanceId = homeInstanceId()): Response {
|
|
39
|
+
if (!instanceId) return response;
|
|
40
|
+
try {
|
|
41
|
+
response.headers.set(SERVED_BY_HEADER, instanceId);
|
|
42
|
+
return response;
|
|
43
|
+
} catch {
|
|
44
|
+
const headers = new Headers(response.headers);
|
|
45
|
+
headers.set(SERVED_BY_HEADER, instanceId);
|
|
46
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The server's last step for every answered request: `GET /v1/models` names the serving Home,
|
|
52
|
+
* whichever branch produced the response. `pathname` is the dispatch path the router matched.
|
|
53
|
+
*/
|
|
54
|
+
export function stampServedBy(method: string, pathname: string, response: Response): Response {
|
|
55
|
+
if (!response || method !== "GET" || pathname !== "/v1/models") return response;
|
|
56
|
+
return withServedBy(response);
|
|
57
|
+
}
|
package/src/responses/parser.ts
CHANGED
|
@@ -329,6 +329,13 @@ export function parseRequest(
|
|
|
329
329
|
continue;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
if (effectiveType === "configuration_update") {
|
|
333
|
+
// Codex 0.164 positional reasoning-effort control. It is not a turn, so a translated route
|
|
334
|
+
// drops it without touching pending reasoning; the native passthrough keeps it verbatim in
|
|
335
|
+
// _rawBody. Mapping its effort onto the routed request is deliberately not done (#6895).
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
332
339
|
if (isCompactionItemType(effectiveType)) {
|
|
333
340
|
// A stored summary from a previous compaction. Decode our ocx1/ocx2 envelopes into plain text so
|
|
334
341
|
// the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note.
|
|
@@ -121,6 +121,18 @@ export function isProviderIssuedThinkingSignature(
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Whether a value has the spelling of a signature Anthropic issued. An allow-list on top of
|
|
126
|
+
* `isProviderIssuedThinkingSignature`: Anthropic's signature is base64, so item ids and the
|
|
127
|
+
* parser's serialized placeholder never pass. Shared by the adapter, which forwards only such a
|
|
128
|
+
* signature, and the Chat `reasoning_details` carrier, which accepts and emits only such a one.
|
|
129
|
+
*/
|
|
130
|
+
export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
|
|
131
|
+
if (typeof signature !== "string" || signature.length < 16) return false;
|
|
132
|
+
if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false;
|
|
133
|
+
return /^[A-Za-z0-9+/_=-]+$/.test(signature);
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */
|
|
125
137
|
export function decodeReasoningEnvelope(encryptedContent: string, budget?: TranslatorBudget): ReasoningEnvelope | null {
|
|
126
138
|
if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCX-11 fallback policy: one resolver, one guard, one record.
|
|
3
|
+
*
|
|
4
|
+
* A fallback is a hop that serves a request from a different model or a different provider than
|
|
5
|
+
* the one chosen or requested. Rotation among the accounts or keys of one provider pool for the
|
|
6
|
+
* same model is intra-pool resilience and never passes through here (see
|
|
7
|
+
* `structure/fallback-policy.md`). Every hop site calls {@link guardFallbackHop} at the point it
|
|
8
|
+
* would switch targets; nothing else reads `config.fallback`.
|
|
9
|
+
*
|
|
10
|
+
* Precedence: a device scope set to false refuses regardless; otherwise route, then provider,
|
|
11
|
+
* then global decide, most specific first; absence allows (today's behavior).
|
|
12
|
+
*
|
|
13
|
+
* Privacy: events carry public selectors, coarse trigger codes and device ids only. No request
|
|
14
|
+
* body, credential, account id, label or email reaches the ring or the refusal body.
|
|
15
|
+
*/
|
|
16
|
+
import type { OcxConfig } from "../types";
|
|
17
|
+
import type { OcxFallbackConfig, OcxFallbackKind, OcxFallbackSwitches } from "../types/fallback";
|
|
18
|
+
import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy";
|
|
19
|
+
|
|
20
|
+
export type FallbackScope = "device" | "route" | "provider" | "global" | "default";
|
|
21
|
+
export type FallbackSite = "combo" | "subagent" | "hard-lock" | "routing-profile";
|
|
22
|
+
export type FallbackTrigger =
|
|
23
|
+
| "rate_limited" | "quota_exhausted" | "auth_refused" | "upstream_5xx" | "upstream_error"
|
|
24
|
+
| "cooldown" | "unavailable" | "hard_lock";
|
|
25
|
+
|
|
26
|
+
export interface FallbackTarget { provider: string; model: string }
|
|
27
|
+
|
|
28
|
+
export interface FallbackDecision {
|
|
29
|
+
allowed: boolean;
|
|
30
|
+
scope: FallbackScope;
|
|
31
|
+
scopeKey?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FallbackEvent {
|
|
35
|
+
at: string;
|
|
36
|
+
site: FallbackSite;
|
|
37
|
+
route: string;
|
|
38
|
+
kind: OcxFallbackKind;
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
trigger: FallbackTrigger;
|
|
42
|
+
outcome: "taken" | "refused";
|
|
43
|
+
scope: FallbackScope;
|
|
44
|
+
scopeKey?: string;
|
|
45
|
+
deviceId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The admission slice this module reads; a configured key is the only device identity. */
|
|
49
|
+
export type FallbackAdmission = { kind: string; keyId?: string } | null | undefined;
|
|
50
|
+
|
|
51
|
+
const RING_SIZE = 20;
|
|
52
|
+
const ring: FallbackEvent[] = [];
|
|
53
|
+
|
|
54
|
+
function switches(value: unknown): OcxFallbackSwitches | undefined {
|
|
55
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as OcxFallbackSwitches : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function policyOf(config: Pick<OcxConfig, "fallback"> | undefined): OcxFallbackConfig | undefined {
|
|
59
|
+
const value = config?.fallback;
|
|
60
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function entry(map: Record<string, unknown> | undefined, key: string, pattern: RegExp): OcxFallbackSwitches | undefined {
|
|
64
|
+
if (!map || typeof map !== "object" || !pattern.test(key) || !Object.hasOwn(map, key)) return undefined;
|
|
65
|
+
return switches(map[key]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function selector(target: FallbackTarget): string {
|
|
69
|
+
return target.provider + "/" + target.model;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The kind of a hop, or null when the target does not change. */
|
|
73
|
+
export function fallbackHopKind(from: FallbackTarget, to: FallbackTarget): OcxFallbackKind | null {
|
|
74
|
+
if (from.provider !== to.provider) return "provider";
|
|
75
|
+
return from.model === to.model ? null : "model";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve one hop against the configured scopes. Pure; device ids are passed in. */
|
|
79
|
+
export function resolveFallbackPolicy(
|
|
80
|
+
config: Pick<OcxConfig, "fallback"> | undefined,
|
|
81
|
+
query: { kind: OcxFallbackKind; routes?: readonly string[]; provider?: string; deviceIds?: readonly string[] },
|
|
82
|
+
): FallbackDecision {
|
|
83
|
+
const policy = policyOf(config);
|
|
84
|
+
if (!policy) return { allowed: true, scope: "default" };
|
|
85
|
+
for (const id of query.deviceIds ?? []) {
|
|
86
|
+
if (entry(policy.devices, id, FALLBACK_DEVICE_ID)?.[query.kind] === false) {
|
|
87
|
+
return { allowed: false, scope: "device", scopeKey: id };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const route of query.routes ?? []) {
|
|
91
|
+
const value = entry(policy.routes, route, FALLBACK_SELECTOR)?.[query.kind];
|
|
92
|
+
if (typeof value === "boolean") return { allowed: value, scope: "route", scopeKey: route };
|
|
93
|
+
}
|
|
94
|
+
if (query.provider) {
|
|
95
|
+
const value = entry(policy.providers, query.provider, FALLBACK_SELECTOR)?.[query.kind];
|
|
96
|
+
if (typeof value === "boolean") return { allowed: value, scope: "provider", scopeKey: query.provider };
|
|
97
|
+
}
|
|
98
|
+
const global = policy[query.kind];
|
|
99
|
+
if (typeof global === "boolean") return { allowed: global, scope: "global" };
|
|
100
|
+
return { allowed: true, scope: "default" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Core-owned slot (AGENTS.md "optional subsystems stay off the core path"): the link listener
|
|
105
|
+
* lifecycle registers how a data key id maps to its Remote Link id, so this request-path module
|
|
106
|
+
* never imports link state. With nothing registered a key id is its own device id (a Hub client).
|
|
107
|
+
*/
|
|
108
|
+
export type FallbackLinkResolver = (keyId: string) => string | undefined;
|
|
109
|
+
let linkResolver: FallbackLinkResolver | undefined;
|
|
110
|
+
|
|
111
|
+
/** Register the key-to-link resolver; returns a disposer that clears it if still current. */
|
|
112
|
+
export function registerFallbackLinkResolver(resolver: FallbackLinkResolver): () => void {
|
|
113
|
+
linkResolver = resolver;
|
|
114
|
+
linkCache = undefined;
|
|
115
|
+
return () => {
|
|
116
|
+
if (linkResolver === resolver) linkResolver = undefined;
|
|
117
|
+
linkCache = undefined;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let linkCache: { at: number; byKey: Map<string, string | undefined> } | undefined;
|
|
122
|
+
const LINK_CACHE_MS = 5_000;
|
|
123
|
+
|
|
124
|
+
/** Test seam: forget the cached link-key map. */
|
|
125
|
+
export function resetFallbackLinkCacheForTests(): void {
|
|
126
|
+
linkCache = undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function linkIdForKey(keyId: string, now: number): string | undefined {
|
|
130
|
+
if (!linkResolver) return undefined;
|
|
131
|
+
if (!linkCache || now - linkCache.at > LINK_CACHE_MS) linkCache = { at: now, byKey: new Map() };
|
|
132
|
+
if (!linkCache.byKey.has(keyId)) {
|
|
133
|
+
let linkId: string | undefined;
|
|
134
|
+
try { linkId = linkResolver(keyId); } catch { /* unreadable store: the key id alone still identifies the device */ }
|
|
135
|
+
linkCache.byKey.set(keyId, linkId);
|
|
136
|
+
}
|
|
137
|
+
return linkCache.byKey.get(keyId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Device ids for a request: the Remote Link id its data key belongs to, then the key id itself
|
|
142
|
+
* (a Hub client). The registered link resolver runs only when a device override exists.
|
|
143
|
+
*/
|
|
144
|
+
export function fallbackDeviceIds(config: Pick<OcxConfig, "fallback">, admission: FallbackAdmission, now = Date.now()): string[] {
|
|
145
|
+
const devices = policyOf(config)?.devices;
|
|
146
|
+
if (!devices || Object.keys(devices).length === 0) return [];
|
|
147
|
+
if (!admission || admission.kind !== "configured" || typeof admission.keyId !== "string") return [];
|
|
148
|
+
const linkId = linkIdForKey(admission.keyId, now);
|
|
149
|
+
return linkId ? [linkId, admission.keyId] : [admission.keyId];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A coarse trigger code for a failed upstream status. */
|
|
153
|
+
export function fallbackTriggerForStatus(status: number): FallbackTrigger {
|
|
154
|
+
if (status === 429) return "rate_limited";
|
|
155
|
+
if (status === 402) return "quota_exhausted";
|
|
156
|
+
if (status === 401 || status === 403) return "auth_refused";
|
|
157
|
+
if (status >= 500) return "upstream_5xx";
|
|
158
|
+
return "upstream_error";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function record(event: FallbackEvent): void {
|
|
162
|
+
ring.push(event);
|
|
163
|
+
if (ring.length > RING_SIZE) ring.splice(0, ring.length - RING_SIZE);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function recentFallbackEvents(): FallbackEvent[] {
|
|
167
|
+
return ring.map(event => ({ ...event }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function clearFallbackEventsForTests(): void {
|
|
171
|
+
ring.length = 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface FallbackHop {
|
|
175
|
+
site: FallbackSite;
|
|
176
|
+
/** The public selector the client asked for (combo/<id>, policy/<id>, a model). */
|
|
177
|
+
route: string;
|
|
178
|
+
/** Further selectors that name the same route, such as a resolved alias. */
|
|
179
|
+
aliases?: readonly string[];
|
|
180
|
+
from: FallbackTarget;
|
|
181
|
+
to: FallbackTarget;
|
|
182
|
+
trigger: FallbackTrigger;
|
|
183
|
+
admission?: FallbackAdmission;
|
|
184
|
+
/** Status of the failure that triggered the hop; absent for a proactive hop. */
|
|
185
|
+
failureStatus?: number;
|
|
186
|
+
retryAfter?: string | null;
|
|
187
|
+
now?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type FallbackGuardResult =
|
|
191
|
+
| {
|
|
192
|
+
ok: true;
|
|
193
|
+
kind: OcxFallbackKind | null;
|
|
194
|
+
/**
|
|
195
|
+
* Record the hop as taken. A site calls it once the fallback dispatch is admitted (send
|
|
196
|
+
* budget reserved, candidate not locally skipped), so the ring never claims a target that was
|
|
197
|
+
* never contacted. Idempotent; a no-op for a same-target retry.
|
|
198
|
+
*/
|
|
199
|
+
commit: () => void;
|
|
200
|
+
}
|
|
201
|
+
| { ok: false; kind: OcxFallbackKind; decision: FallbackDecision; response: Response };
|
|
202
|
+
|
|
203
|
+
const NO_COMMIT = (): void => {};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check one hop. A refusal is recorded at once; an allowed hop is recorded only through the
|
|
207
|
+
* returned `commit`. A same-target retry is not a hop and always passes unrecorded.
|
|
208
|
+
*/
|
|
209
|
+
export function guardFallbackHop(config: OcxConfig, hop: FallbackHop): FallbackGuardResult {
|
|
210
|
+
const kind = fallbackHopKind(hop.from, hop.to);
|
|
211
|
+
if (kind === null) return { ok: true, kind: null, commit: NO_COMMIT };
|
|
212
|
+
const now = hop.now ?? Date.now();
|
|
213
|
+
const deviceIds = fallbackDeviceIds(config, hop.admission, now);
|
|
214
|
+
const routes = [hop.route, ...(hop.aliases ?? [])].filter((value, index, all) => value && all.indexOf(value) === index);
|
|
215
|
+
const decision = resolveFallbackPolicy(config, { kind, routes, provider: hop.from.provider, deviceIds });
|
|
216
|
+
const event: FallbackEvent = {
|
|
217
|
+
at: new Date(now).toISOString(),
|
|
218
|
+
site: hop.site,
|
|
219
|
+
route: hop.route,
|
|
220
|
+
kind,
|
|
221
|
+
from: selector(hop.from),
|
|
222
|
+
to: selector(hop.to),
|
|
223
|
+
trigger: hop.trigger,
|
|
224
|
+
outcome: decision.allowed ? "taken" : "refused",
|
|
225
|
+
scope: decision.scope,
|
|
226
|
+
...(decision.scopeKey !== undefined ? { scopeKey: decision.scopeKey } : {}),
|
|
227
|
+
...(deviceIds[0] !== undefined ? { deviceId: deviceIds[0] } : {}),
|
|
228
|
+
};
|
|
229
|
+
if (decision.allowed) {
|
|
230
|
+
let committed = false;
|
|
231
|
+
return { ok: true, kind, commit: () => { if (!committed) { committed = true; record(event); } } };
|
|
232
|
+
}
|
|
233
|
+
record(event);
|
|
234
|
+
return { ok: false, kind, decision, response: fallbackRefusedResponse(event, hop) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scopeFlag(event: FallbackEvent): string {
|
|
238
|
+
switch (event.scope) {
|
|
239
|
+
case "device": return " --device " + event.scopeKey;
|
|
240
|
+
case "route": return " --route " + event.scopeKey;
|
|
241
|
+
case "provider": return " --provider " + event.scopeKey;
|
|
242
|
+
default: return "";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function scopePhrase(event: FallbackEvent): string {
|
|
247
|
+
return event.scope === "global" ? "globally" : "for " + event.scope + " " + event.scopeKey;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The stable `fallback_refused` error. Status follows the triggering failure. */
|
|
251
|
+
export function fallbackRefusedResponse(event: FallbackEvent, hop: Pick<FallbackHop, "failureStatus" | "retryAfter">): Response {
|
|
252
|
+
const status = typeof hop.failureStatus === "number" && hop.failureStatus >= 400 && hop.failureStatus <= 599
|
|
253
|
+
? hop.failureStatus
|
|
254
|
+
: event.trigger === "cooldown" || event.trigger === "quota_exhausted" || event.trigger === "rate_limited"
|
|
255
|
+
|| event.trigger === "hard_lock" ? 429 : 503;
|
|
256
|
+
const kindWord = event.kind === "provider" ? "Provider" : "Model";
|
|
257
|
+
const message = event.route + ": " + event.from + " failed (" + event.trigger + "). " + kindWord
|
|
258
|
+
+ " fallback to " + event.to + " is off " + scopePhrase(event) + ", so OpenCodex did not send the request there."
|
|
259
|
+
+ " To allow it: ocx fallback on --kind " + event.kind + scopeFlag(event);
|
|
260
|
+
const headers = new Headers({ "x-opencodex-fallback": "refused", "cache-control": "no-store" });
|
|
261
|
+
if (hop.retryAfter) headers.set("retry-after", hop.retryAfter);
|
|
262
|
+
return Response.json({
|
|
263
|
+
error: {
|
|
264
|
+
message,
|
|
265
|
+
type: "fallback_refused",
|
|
266
|
+
code: "fallback_refused",
|
|
267
|
+
fallback: {
|
|
268
|
+
kind: event.kind,
|
|
269
|
+
scope: event.scope,
|
|
270
|
+
...(event.scopeKey !== undefined ? { scopeKey: event.scopeKey } : {}),
|
|
271
|
+
from: event.from,
|
|
272
|
+
to: event.to,
|
|
273
|
+
trigger: event.trigger,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}, { status, headers });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether a response is this module's refusal. */
|
|
280
|
+
export function isFallbackRefusal(response: Response): boolean {
|
|
281
|
+
return response.headers.get("x-opencodex-fallback") === "refused";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Carries a refusal out of a site that cannot return a Response itself. */
|
|
285
|
+
export class FallbackRefusedError extends Error {
|
|
286
|
+
constructor(readonly response: Response) {
|
|
287
|
+
super("fallback_refused");
|
|
288
|
+
this.name = "FallbackRefusedError";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCX-11 fallback state: the `ocx.fallback/1` read model and the one scoped write.
|
|
3
|
+
*
|
|
4
|
+
* Off the request path: the management route, `ocx fallback` and `ocx status --json` use it.
|
|
5
|
+
* The request path only resolves (`src/routing/fallback-policy.ts`).
|
|
6
|
+
*/
|
|
7
|
+
import type { OcxConfig } from "../types";
|
|
8
|
+
import type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackSwitches } from "../types/fallback";
|
|
9
|
+
import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy";
|
|
10
|
+
import type { LinkRecord } from "../link/store";
|
|
11
|
+
import { recentFallbackEvents, type FallbackEvent } from "./fallback-policy";
|
|
12
|
+
|
|
13
|
+
export const FALLBACK_STATE_SCHEMA = "ocx.fallback/1";
|
|
14
|
+
|
|
15
|
+
export interface FallbackDeviceView {
|
|
16
|
+
id: string;
|
|
17
|
+
kind: "link" | "key";
|
|
18
|
+
alias?: string;
|
|
19
|
+
/** Permission for GOAT to move this device's work to its own providers while the Home is down. */
|
|
20
|
+
localFallback: boolean;
|
|
21
|
+
/** false: refused for this device whatever the other scopes say; null: not set. */
|
|
22
|
+
model: boolean | null;
|
|
23
|
+
provider: boolean | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FallbackStateView {
|
|
27
|
+
schema: typeof FALLBACK_STATE_SCHEMA;
|
|
28
|
+
/** Effective global switches; absence reads true, today's behavior. */
|
|
29
|
+
global: { model: boolean; provider: boolean };
|
|
30
|
+
providers: Record<string, OcxFallbackSwitches>;
|
|
31
|
+
routes: Record<string, OcxFallbackSwitches>;
|
|
32
|
+
devices: FallbackDeviceView[];
|
|
33
|
+
last: FallbackEvent | null;
|
|
34
|
+
recent: FallbackEvent[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cleanSwitches(value: unknown): OcxFallbackSwitches {
|
|
38
|
+
const out: OcxFallbackSwitches = {};
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
|
40
|
+
const raw = value as Record<string, unknown>;
|
|
41
|
+
if (typeof raw.model === "boolean") out.model = raw.model;
|
|
42
|
+
if (typeof raw.provider === "boolean") out.provider = raw.provider;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanMap(value: unknown, pattern: RegExp): Record<string, OcxFallbackSwitches> {
|
|
47
|
+
const out: Record<string, OcxFallbackSwitches> = {};
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
|
49
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
50
|
+
if (!pattern.test(key)) continue;
|
|
51
|
+
const switches = cleanSwitches(entry);
|
|
52
|
+
if (Object.keys(switches).length > 0) out[key] = switches;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build the read model. `passive` (a Remote Link Child's read through the management relay)
|
|
59
|
+
* drops link aliases and per-event device ids, so a Child learns its own row by link id only.
|
|
60
|
+
*/
|
|
61
|
+
export function buildFallbackState(
|
|
62
|
+
config: Pick<OcxConfig, "fallback">,
|
|
63
|
+
options: { links?: readonly Pick<LinkRecord, "id" | "alias" | "apiKeyId">[]; passive?: boolean } = {},
|
|
64
|
+
): FallbackStateView {
|
|
65
|
+
const policy: OcxFallbackConfig = config.fallback ?? {};
|
|
66
|
+
const devicesRaw = policy.devices && typeof policy.devices === "object" ? policy.devices : {};
|
|
67
|
+
const rows = new Map<string, FallbackDeviceView>();
|
|
68
|
+
const deviceRow = (id: string, kind: "link" | "key", alias?: string): FallbackDeviceView => {
|
|
69
|
+
const raw = (Object.hasOwn(devicesRaw, id) ? devicesRaw[id] : undefined) as OcxFallbackDeviceConfig | undefined;
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
kind,
|
|
73
|
+
...(alias && !options.passive ? { alias } : {}),
|
|
74
|
+
localFallback: raw?.localFallback === true,
|
|
75
|
+
model: typeof raw?.model === "boolean" ? raw.model : null,
|
|
76
|
+
provider: typeof raw?.provider === "boolean" ? raw.provider : null,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
for (const link of options.links ?? []) rows.set(link.id, deviceRow(link.id, "link", link.alias));
|
|
80
|
+
for (const id of Object.keys(devicesRaw)) {
|
|
81
|
+
if (!FALLBACK_DEVICE_ID.test(id) || rows.has(id)) continue;
|
|
82
|
+
rows.set(id, deviceRow(id, id.startsWith("lnk_") ? "link" : "key"));
|
|
83
|
+
}
|
|
84
|
+
const recent = recentFallbackEvents().map(event => {
|
|
85
|
+
if (!options.passive || event.deviceId === undefined) return event;
|
|
86
|
+
const { deviceId: _omitted, ...rest } = event;
|
|
87
|
+
return rest;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
schema: FALLBACK_STATE_SCHEMA,
|
|
91
|
+
global: { model: policy.model !== false, provider: policy.provider !== false },
|
|
92
|
+
providers: cleanMap(policy.providers, FALLBACK_SELECTOR),
|
|
93
|
+
routes: cleanMap(policy.routes, FALLBACK_SELECTOR),
|
|
94
|
+
devices: [...rows.values()],
|
|
95
|
+
last: recent.at(-1) ?? null,
|
|
96
|
+
recent,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type FallbackWriteScope = "global" | "provider" | "route" | "device";
|
|
101
|
+
export type FallbackWriteSetting = "model" | "provider" | "localFallback";
|
|
102
|
+
|
|
103
|
+
export interface FallbackWrite {
|
|
104
|
+
scope: FallbackWriteScope;
|
|
105
|
+
key?: string;
|
|
106
|
+
setting: FallbackWriteSetting;
|
|
107
|
+
/** true or false sets the leaf; null removes it so the scope inherits again. */
|
|
108
|
+
value: boolean | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Validate a write body; the error text is operator-facing. */
|
|
112
|
+
export function parseFallbackWrite(body: unknown): { ok: true; write: FallbackWrite } | { ok: false; error: string } {
|
|
113
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "expected a JSON object" };
|
|
114
|
+
const raw = body as Record<string, unknown>;
|
|
115
|
+
const scope = raw.scope;
|
|
116
|
+
const setting = raw.setting;
|
|
117
|
+
const value = raw.value;
|
|
118
|
+
if (scope !== "global" && scope !== "provider" && scope !== "route" && scope !== "device") {
|
|
119
|
+
return { ok: false, error: "scope must be global, provider, route or device" };
|
|
120
|
+
}
|
|
121
|
+
if (setting !== "model" && setting !== "provider" && setting !== "localFallback") {
|
|
122
|
+
return { ok: false, error: "setting must be model, provider or localFallback" };
|
|
123
|
+
}
|
|
124
|
+
if (value !== true && value !== false && value !== null) return { ok: false, error: "value must be true, false or null" };
|
|
125
|
+
if (setting === "localFallback" && scope !== "device") return { ok: false, error: "localFallback is set per device" };
|
|
126
|
+
if (scope === "global") {
|
|
127
|
+
if (raw.key !== undefined) return { ok: false, error: "a global write takes no key" };
|
|
128
|
+
return { ok: true, write: { scope, setting, value } };
|
|
129
|
+
}
|
|
130
|
+
const key = raw.key;
|
|
131
|
+
const pattern = scope === "device" ? FALLBACK_DEVICE_ID : FALLBACK_SELECTOR;
|
|
132
|
+
if (typeof key !== "string" || !pattern.test(key)) return { ok: false, error: scope + " key is missing or malformed" };
|
|
133
|
+
return { ok: true, write: { scope, key, setting, value } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Up to 8 writes applied in one config mutation: `{ writes: [...] }`, or a single write. */
|
|
137
|
+
export const MAX_FALLBACK_WRITES = 8;
|
|
138
|
+
|
|
139
|
+
export function parseFallbackWrites(body: unknown): { ok: true; writes: FallbackWrite[] } | { ok: false; error: string } {
|
|
140
|
+
const list = body && typeof body === "object" && !Array.isArray(body) && Object.hasOwn(body, "writes")
|
|
141
|
+
? (body as { writes: unknown }).writes
|
|
142
|
+
: [body];
|
|
143
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_FALLBACK_WRITES) {
|
|
144
|
+
return { ok: false, error: "writes must hold 1 to " + MAX_FALLBACK_WRITES + " entries" };
|
|
145
|
+
}
|
|
146
|
+
const writes: FallbackWrite[] = [];
|
|
147
|
+
for (const entry of list) {
|
|
148
|
+
const parsed = parseFallbackWrite(entry);
|
|
149
|
+
if (!parsed.ok) return parsed;
|
|
150
|
+
writes.push(parsed.write);
|
|
151
|
+
}
|
|
152
|
+
return { ok: true, writes };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setLeaf(target: Record<string, unknown>, setting: string, value: boolean | null): void {
|
|
156
|
+
if (value === null) delete target[setting];
|
|
157
|
+
else target[setting] = value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply one write to a config in place; reports whether anything changed. */
|
|
161
|
+
export function applyFallbackWrite(config: OcxConfig, write: FallbackWrite): boolean {
|
|
162
|
+
const before = JSON.stringify(config.fallback ?? null);
|
|
163
|
+
const policy: Record<string, unknown> = { ...(config.fallback ?? {}) };
|
|
164
|
+
if (write.scope === "global") {
|
|
165
|
+
setLeaf(policy, write.setting, write.value);
|
|
166
|
+
} else {
|
|
167
|
+
const mapName = write.scope === "provider" ? "providers" : write.scope === "route" ? "routes" : "devices";
|
|
168
|
+
const map: Record<string, Record<string, unknown>> = { ...((policy[mapName] as Record<string, Record<string, unknown>> | undefined) ?? {}) };
|
|
169
|
+
const entry = { ...(Object.hasOwn(map, write.key!) ? map[write.key!] : {}) };
|
|
170
|
+
setLeaf(entry, write.setting, write.value);
|
|
171
|
+
if (Object.keys(entry).length > 0) map[write.key!] = entry;
|
|
172
|
+
else delete map[write.key!];
|
|
173
|
+
if (Object.keys(map).length > 0) policy[mapName] = map;
|
|
174
|
+
else delete policy[mapName];
|
|
175
|
+
}
|
|
176
|
+
if (Object.keys(policy).length > 0) config.fallback = policy as OcxFallbackConfig;
|
|
177
|
+
else delete config.fallback;
|
|
178
|
+
return JSON.stringify(config.fallback ?? null) !== before;
|
|
179
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
|
|
|
4
4
|
import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
|
|
5
5
|
import { isProxyAdmissionSecret, type DataPlaneAdmission, type DataPlaneAdmissionOptions } from "./auth-cors";
|
|
6
6
|
import { resolveAudioAdmission } from "./audio-upstream";
|
|
7
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
7
8
|
|
|
8
9
|
export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio";
|
|
9
10
|
const KEY_PROTOCOL_PREFIX = "opencodex-key.";
|
|
@@ -61,7 +62,7 @@ export function resolveAudioClient(
|
|
|
61
62
|
: null;
|
|
62
63
|
}
|
|
63
64
|
const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key")
|
|
64
|
-
: admission.source === "x-api-key" ? headers.get("x-api-key") : headers
|
|
65
|
+
: admission.source === "x-api-key" ? headers.get("x-api-key") : authorizationCredential(headers);
|
|
65
66
|
const owner = admission.kind === "configured"
|
|
66
67
|
? JSON.stringify(["configured", admission.keyId])
|
|
67
68
|
: JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]);
|
|
@@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
|
|
|
4
4
|
import type { AudioClient } from "./audio-client";
|
|
5
5
|
import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream";
|
|
6
6
|
import type { RequestLogContext } from "./request-log";
|
|
7
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
7
8
|
|
|
8
9
|
export const DICTATION_SESSION_MAX_MS = 300_000;
|
|
9
10
|
const DICTATION_FRAME_MAX_BYTES = 64 * 1024;
|
|
@@ -74,7 +75,7 @@ export async function resolveDictationSocket(
|
|
|
74
75
|
return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account");
|
|
75
76
|
}
|
|
76
77
|
const headers = new Headers(relay.headers);
|
|
77
|
-
const token = headers
|
|
78
|
+
const token = authorizationCredential(headers);
|
|
78
79
|
if (!token || /[\s,]/.test(token)) {
|
|
79
80
|
relay.release();
|
|
80
81
|
return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable");
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./auth-cors";
|
|
25
25
|
import { codexAccountSelectionForTurn } from "./lifecycle";
|
|
26
26
|
import type { RequestLogContext } from "./request-log";
|
|
27
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
27
28
|
|
|
28
29
|
export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
|
|
29
30
|
export const LIVE_AUDIO_MODEL = "gpt-live-1-codex";
|
|
@@ -77,7 +78,7 @@ export async function resolveAudioUpstream(
|
|
|
77
78
|
if (candidates.keyed?.providerName !== options.providerName) delete candidates.keyed;
|
|
78
79
|
}
|
|
79
80
|
const headers = new Headers(incoming);
|
|
80
|
-
const bearer = headers
|
|
81
|
+
const bearer = authorizationCredential(headers);
|
|
81
82
|
if (bearer && isProxyAdmissionSecret(bearer, config)) {
|
|
82
83
|
headers.delete("authorization");
|
|
83
84
|
headers.delete("chatgpt-account-id");
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -47,6 +47,7 @@ import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compac
|
|
|
47
47
|
import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing";
|
|
48
48
|
import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
|
|
49
49
|
import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
|
|
50
|
+
import { authorizationCredential } from "../lib/authorization-scheme";
|
|
50
51
|
|
|
51
52
|
let _corsOrigin = "http://localhost:10100";
|
|
52
53
|
// Listener identity is process-local. Never infer this trust from forwarded headers: the
|
|
@@ -501,7 +502,7 @@ export function resolveContextPrincipal(req: Request, config: OcxConfig, admissi
|
|
|
501
502
|
if (named) return named;
|
|
502
503
|
if (admission?.kind !== "loopback") return undefined;
|
|
503
504
|
const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
|
|
504
|
-
const bearer = req.headers
|
|
505
|
+
const bearer = authorizationCredential(req.headers);
|
|
505
506
|
const apiKey = req.headers.get("x-api-key")?.trim();
|
|
506
507
|
for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) {
|
|
507
508
|
if (!token) continue;
|
|
@@ -600,13 +601,13 @@ export class ForwardAdmissionCredentialError extends Error {
|
|
|
600
601
|
}
|
|
601
602
|
|
|
602
603
|
export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
|
|
603
|
-
const bearer = headers
|
|
604
|
+
const bearer = authorizationCredential(headers);
|
|
604
605
|
if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
|
|
605
606
|
}
|
|
606
607
|
|
|
607
608
|
/** Whether Authorization carries a caller-owned native Codex credential safe to forward. */
|
|
608
609
|
export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean {
|
|
609
|
-
const bearer = headers
|
|
610
|
+
const bearer = authorizationCredential(headers);
|
|
610
611
|
const accountId = headers.get("chatgpt-account-id")?.trim()
|
|
611
612
|
|| (bearer ? extractAccountId(undefined, bearer) : undefined);
|
|
612
613
|
return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config);
|
|
@@ -621,7 +622,7 @@ export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPla
|
|
|
621
622
|
if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" };
|
|
622
623
|
const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
|
|
623
624
|
if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", { linkIngress: config.linkIngress?.allowedKeyIds });
|
|
624
|
-
const bearer = req.headers
|
|
625
|
+
const bearer = authorizationCredential(req.headers);
|
|
625
626
|
if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
|
|
626
627
|
// Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
|
|
627
628
|
const apiKey = req.headers.get("x-api-key")?.trim();
|
|
@@ -653,7 +654,7 @@ export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView)
|
|
|
653
654
|
// safe because the upstream credential is then SUBSTITUTED rather than forwarded -- see
|
|
654
655
|
// materializeCodexUpstreamAuth. A bearer that is NOT our secret stays unadmitted here and
|
|
655
656
|
// remains Codex Direct passthrough, so the two bearer domains still never mix.
|
|
656
|
-
const bearer = req.headers
|
|
657
|
+
const bearer = authorizationCredential(req.headers);
|
|
657
658
|
if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
|
|
658
659
|
// `x-api-key` is deliberately NOT accepted on this transport.
|
|
659
660
|
return null;
|