@bitkyc08/opencodex 2.89.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-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- 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/ensure-desktop-cold-start.ts +2 -2
- 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/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -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/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- 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/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- 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/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- 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/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- 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/chat-native.ts +8 -7
- 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 +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- 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/oauth-account-routes.ts +2 -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/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- 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/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -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;
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
startMemoryWatchdog,
|
|
33
33
|
type MemoryWatchdog,
|
|
34
34
|
} from "./memory-watchdog";
|
|
35
|
+
import { ensureQuotaPoolIdentity } from "../quota-pool/identity";
|
|
35
36
|
|
|
36
37
|
type PolicyApply = (policy: StorageCleanupPolicy) => void;
|
|
37
38
|
|
|
@@ -204,6 +205,9 @@ export function acquireServerBackgroundLifecycle(
|
|
|
204
205
|
if (cleanupInProgress) {
|
|
205
206
|
throw new Error("server background lifecycle cleanup is still in progress");
|
|
206
207
|
}
|
|
208
|
+
// The quota-pool identity (homeId and the accountRef key) is minted here, per server and off
|
|
209
|
+
// the request path, so GET /api/quota-pool only ever reads it.
|
|
210
|
+
try { ensureQuotaPoolIdentity(); } catch { /* the read reports quota_pool_identity_unavailable */ }
|
|
207
211
|
|
|
208
212
|
const owner: LeaseOwner = {
|
|
209
213
|
token: Symbol("server-background-lifecycle"),
|
|
@@ -81,6 +81,7 @@ import { createNativeChatComboSource, handleNativeChatCompletions, nativeChatDec
|
|
|
81
81
|
import { upstreamWireForAdapter, type ProtocolReasonCode } from "../protocols/contract";
|
|
82
82
|
import { createProtocolEnvelope } from "../protocols/envelope";
|
|
83
83
|
import { featuresFromChatBody } from "../protocols/features";
|
|
84
|
+
import { credentialDomainFor } from "../protocols/opaque-state";
|
|
84
85
|
import { checkRepresentable, unrepresentableMessage } from "../protocols/guard";
|
|
85
86
|
import { requestPathForLane } from "../protocols/path";
|
|
86
87
|
import { resolveProtocolSettings } from "../protocols/settings";
|
|
@@ -344,7 +345,13 @@ async function handleChatCompletionsWithBudget(
|
|
|
344
345
|
directConversation = chatConversationToIr(chatBody, chatBody.model as string);
|
|
345
346
|
internalBody = chatSettingsBody(chatBody);
|
|
346
347
|
} else {
|
|
347
|
-
internalBody = chatToResponsesBody(chatBody
|
|
348
|
+
internalBody = chatToResponsesBody(chatBody, {
|
|
349
|
+
// #7429: only a route that is and stays first-party Anthropic may receive signed thinking
|
|
350
|
+
// a Chat client returns; a combo, policy or rewritten route resolves its target later.
|
|
351
|
+
acceptAnthropicReasoningSignatures: settledRoute !== null && !routeMayChangeCredentialDomain
|
|
352
|
+
&& settledRoute.provider.adapter === "anthropic"
|
|
353
|
+
&& credentialDomainFor(settledRoute.provider)?.firstPartyAnthropic === true,
|
|
354
|
+
});
|
|
348
355
|
}
|
|
349
356
|
if (effortRow) {
|
|
350
357
|
internalBody.reasoning = {
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
applyUpstreamRecoveryInit,
|
|
32
32
|
fetchWithResetRetry,
|
|
33
33
|
fetchWithTransientRetry,
|
|
34
|
-
isNonReplayableResponse,
|
|
34
|
+
isNonReplayableResponse, isSameTargetRetryHold,
|
|
35
35
|
isReplayRefusalCode,
|
|
36
36
|
isReplayRefusalResponse,
|
|
37
37
|
prepareSameTarget429Wait,
|
|
@@ -45,10 +45,7 @@ import {
|
|
|
45
45
|
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
46
46
|
wrapWithZeroOutputRefetch,
|
|
47
47
|
} from "../lib/upstream-retry";
|
|
48
|
-
import {
|
|
49
|
-
isTranslatorBudgetExceededError,
|
|
50
|
-
type TranslatorBudget,
|
|
51
|
-
} from "../lib/translator-budget";
|
|
48
|
+
import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
|
|
52
49
|
import { authorizeResendForRecovery } from "../lib/request-resend-gate";
|
|
53
50
|
import { ambiguousResendAllowanceFor, selfContainedChatBody } from "./responses/reset-replay";
|
|
54
51
|
import {
|
|
@@ -88,6 +85,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
|
|
|
88
85
|
import type { ComboProtocolSource } from "./responses/core-combo-native";
|
|
89
86
|
import type { ProtocolEnvelope } from "../protocols/envelope";
|
|
90
87
|
import { nativeChatReplayScope, bindNativeChatReplayScope, nativeChatReasoningCapture } from "./chat-native-reasoning";
|
|
88
|
+
import { resolveMistralTransport } from "../providers/mistral-transport";
|
|
91
89
|
|
|
92
90
|
export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility";
|
|
93
91
|
|
|
@@ -335,7 +333,8 @@ export async function runNativeChatAttempt(
|
|
|
335
333
|
refusal.headers.set("Retry-After", String(error.retryAfterSeconds));
|
|
336
334
|
return refusal;
|
|
337
335
|
}
|
|
338
|
-
|
|
336
|
+
// Key selection re-resolves the transport; a single-key route still needs canonical Mistral pacing.
|
|
337
|
+
let activeProvider: OcxProviderConfig = resolveMistralTransport(route.provider);
|
|
339
338
|
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
340
339
|
const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx);
|
|
341
340
|
const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
|
|
@@ -365,6 +364,8 @@ export async function runNativeChatAttempt(
|
|
|
365
364
|
config.fastMode,
|
|
366
365
|
route.providerName,
|
|
367
366
|
replayScope,
|
|
367
|
+
undefined,
|
|
368
|
+
translatorBudget,
|
|
368
369
|
);
|
|
369
370
|
observeAdvisorNativeReasoning(req.headers, logCtx, request.body);
|
|
370
371
|
return request;
|
|
@@ -610,7 +611,7 @@ export async function runNativeChatAttempt(
|
|
|
610
611
|
// limit: waiting and re-sending here is exactly the duplicate inference the refusal
|
|
611
612
|
// exists to stop. It kept the same shape under the old 502 only because 502 never
|
|
612
613
|
// matched this branch.
|
|
613
|
-
&& !isNonReplayableResponse(response)
|
|
614
|
+
&& !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
|
|
614
615
|
&& retryPolicy
|
|
615
616
|
&& retries < retryPolicy.attempts
|
|
616
617
|
&& transientSendAvailable()
|