@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/router.ts
CHANGED
|
@@ -152,6 +152,15 @@ function configuredOriginForLog(url: string): string {
|
|
|
152
152
|
// `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple.
|
|
153
153
|
// Keyed by the URLs too: editing config.json to a different wrong value warns again.
|
|
154
154
|
const discardedBaseUrlWarnings = new Set<string>();
|
|
155
|
+
let lastWarningReconciledGeneration = 0;
|
|
156
|
+
|
|
157
|
+
export function reconcileRouterWarningMemos(generation: number): number {
|
|
158
|
+
if (generation <= lastWarningReconciledGeneration) return 0;
|
|
159
|
+
const removed = discardedBaseUrlWarnings.size;
|
|
160
|
+
discardedBaseUrlWarnings.clear();
|
|
161
|
+
lastWarningReconciledGeneration = generation;
|
|
162
|
+
return removed;
|
|
163
|
+
}
|
|
155
164
|
|
|
156
165
|
/**
|
|
157
166
|
* A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved
|
|
@@ -247,6 +256,9 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
247
256
|
...provider,
|
|
248
257
|
adapter: registryEntry.adapter,
|
|
249
258
|
baseUrl,
|
|
259
|
+
...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
|
|
260
|
+
? { responsesPath: registryEntry.responsesPath }
|
|
261
|
+
: {}),
|
|
250
262
|
authMode: canonicalAuthMode,
|
|
251
263
|
apiKey: resolvedApiKey,
|
|
252
264
|
// Backfill the Google wire mode + Vertex project/location from the registry when the user
|
|
@@ -303,8 +315,13 @@ export class NoEnabledOpenAiProviderError extends Error {
|
|
|
303
315
|
}
|
|
304
316
|
}
|
|
305
317
|
|
|
318
|
+
// Codex uses a small number of control-plane model ids that are not part of the public GPT/o
|
|
319
|
+
// naming families. Keep this exact: a broad `codex-*` rule could capture a third-party model.
|
|
320
|
+
const CODEX_INTERNAL_OPENAI_MODELS = new Set(["codex-auto-review"]);
|
|
321
|
+
|
|
306
322
|
function isBareOpenAiFamilyModel(modelId: string): boolean {
|
|
307
|
-
return !modelId.includes("/")
|
|
323
|
+
return !modelId.includes("/")
|
|
324
|
+
&& (/^(?:gpt-|o1-|o3-|o4-)/.test(modelId) || CODEX_INTERNAL_OPENAI_MODELS.has(modelId));
|
|
308
325
|
}
|
|
309
326
|
|
|
310
327
|
function routeResult(providerName: string, provider: OcxProviderConfig, modelId: string): RouteResult {
|
|
@@ -9,22 +9,39 @@ import { createResponsesPassthroughAdapter } from "../adapters/openai-responses"
|
|
|
9
9
|
import type { OcxProviderConfig } from "../types";
|
|
10
10
|
import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types";
|
|
11
11
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
12
|
+
import { type InboundWire, providerModelWireDefault } from "../providers/registry";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Resolve the wire a single model should use: a hard pin first, then a configured
|
|
15
|
-
* per-model override, then the provider's
|
|
16
|
+
* per-model override, then a registry default for a mixed-wire provider, then the provider's
|
|
17
|
+
* own adapter.
|
|
16
18
|
*
|
|
17
19
|
* Safe to call more than once on its own output — the pin check does not look at the
|
|
18
20
|
* current adapter, so a second pass cannot let an override displace a pin.
|
|
21
|
+
*
|
|
22
|
+
* `inbound` is the protocol the ORIGINAL client spoke. It defaults to `"responses"`
|
|
23
|
+
* because the Chat and Anthropic surfaces translate into a Responses-shaped body and
|
|
24
|
+
* replay through `handleResponses`; those two callers pass their real inbound so a
|
|
25
|
+
* scoped registry default cannot fire for a client that never asked for that wire.
|
|
19
26
|
*/
|
|
20
|
-
export function resolveWireProtocolOverride(
|
|
27
|
+
export function resolveWireProtocolOverride(
|
|
28
|
+
providerName: string,
|
|
29
|
+
modelId: string,
|
|
30
|
+
providerConfig: OcxProviderConfig,
|
|
31
|
+
inbound: InboundWire = "responses",
|
|
32
|
+
): OcxProviderConfig {
|
|
21
33
|
const pinned = pinnedWireAdapter(providerName, modelId);
|
|
22
34
|
if (pinned && providerConfig.adapter !== pinned) {
|
|
23
35
|
return { ...providerConfig, adapter: pinned };
|
|
24
36
|
}
|
|
25
37
|
// Re-check the allow-list here, not just in the config validator: the file may have
|
|
26
38
|
// been hand-edited, or written by a build that allowed more values.
|
|
27
|
-
const
|
|
39
|
+
const configured = providerConfig.modelAdapters?.[modelId];
|
|
40
|
+
// An explicit allowed override wins, including one naming the provider-wide adapter (the
|
|
41
|
+
// opt-out from a registry default). Invalid hand-edited values fall through to the default.
|
|
42
|
+
const requested = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)
|
|
43
|
+
? configured
|
|
44
|
+
: providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound);
|
|
28
45
|
if (requested
|
|
29
46
|
&& MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested)
|
|
30
47
|
&& requested !== providerConfig.adapter
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
reasoningSummaryDeliveryRecordConfigError,
|
|
13
13
|
} from "../config";
|
|
14
14
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
15
|
-
import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry";
|
|
15
|
+
import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
|
|
16
16
|
import { providerConfigSeed } from "../providers/derive";
|
|
17
17
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
18
18
|
import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
|
|
@@ -70,16 +70,6 @@ export function isSameOriginAsRequest(req: Request, origin: string): boolean {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean {
|
|
73
|
-
function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
|
|
74
|
-
if (!cfg.corsAllowOrigins?.length) return false;
|
|
75
|
-
return cfg.corsAllowOrigins.some(allowed => {
|
|
76
|
-
try {
|
|
77
|
-
return new URL(allowed).origin === new URL(origin).origin;
|
|
78
|
-
} catch {
|
|
79
|
-
return allowed === origin;
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
73
|
const origin = req.headers.get("Origin");
|
|
84
74
|
if (!isApiAuthRequired(config)) {
|
|
85
75
|
if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
|
|
@@ -88,6 +78,17 @@ export function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean
|
|
|
88
78
|
return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
|
|
89
79
|
}
|
|
90
80
|
|
|
81
|
+
function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
|
|
82
|
+
if (!cfg.corsAllowOrigins?.length) return false;
|
|
83
|
+
return cfg.corsAllowOrigins.some(allowed => {
|
|
84
|
+
try {
|
|
85
|
+
return new URL(allowed).origin === new URL(origin).origin;
|
|
86
|
+
} catch {
|
|
87
|
+
return allowed === origin;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
91
92
|
export function managementRequestOrigin(req: Request, config: OcxConfig): string | null {
|
|
92
93
|
const host = req.headers.get("Host");
|
|
93
94
|
const parsedHost = parseHttpHost(host);
|
|
@@ -106,7 +107,9 @@ export function isAllowedManagementOrigin(req: Request, config: OcxConfig): bool
|
|
|
106
107
|
const requestOrigin = managementRequestOrigin(req, config);
|
|
107
108
|
if (!requestOrigin) return false;
|
|
108
109
|
const origin = req.headers.get("Origin");
|
|
109
|
-
|
|
110
|
+
// Exact match against the process-derived origin, or an operator-listed corsAllowOrigins
|
|
111
|
+
// entry (covers TLS-terminator https://… when the process observes http://…).
|
|
112
|
+
return !origin || origin === requestOrigin || isExtraAllowedOrigin(origin, config);
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
export function browserSecurityHeaders(): Record<string, string> {
|
|
@@ -210,17 +213,87 @@ function secretEquals(actual: string, expected: string | undefined): boolean {
|
|
|
210
213
|
return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
211
214
|
}
|
|
212
215
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Which admission a data-plane request used.
|
|
218
|
+
*
|
|
219
|
+
* `configured` carries the matched key's id so a request can be attributed to
|
|
220
|
+
* the key that opened it. The other two exist so an unattributed request is a
|
|
221
|
+
* stated fact rather than a missing field: neither has a configured entry to
|
|
222
|
+
* point at, and a sentinel string in the id would collide with a hand-edited
|
|
223
|
+
* entry that happens to be named `loopback`.
|
|
224
|
+
*/
|
|
225
|
+
export type DataPlaneAdmission =
|
|
226
|
+
| { kind: "configured"; keyId: string }
|
|
227
|
+
| { kind: "environment" }
|
|
228
|
+
| { kind: "loopback" };
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Which admission secret `token` is, or null when it is none of them.
|
|
232
|
+
*
|
|
233
|
+
* Identical comparisons in an identical order to the boolean form this replaces —
|
|
234
|
+
* `secretEquals` still length-guards before `timingSafeEqual`. The only
|
|
235
|
+
* difference is that the matched entry's id survives the loop instead of being
|
|
236
|
+
* discarded, which is what makes per-key attribution possible without touching
|
|
237
|
+
* the admission decision itself.
|
|
238
|
+
*/
|
|
239
|
+
export function resolveDataPlaneAdmissionSecret(token: string, config: OcxConfig): DataPlaneAdmission | null {
|
|
215
240
|
const actual = token.trim();
|
|
216
|
-
if (!actual) return
|
|
217
|
-
if (secretEquals(actual, configuredApiAuthToken(config))) return
|
|
241
|
+
if (!actual) return null;
|
|
242
|
+
if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment" };
|
|
218
243
|
for (const k of config.apiKeys ?? []) {
|
|
219
|
-
if (secretEquals(actual, k.key)) return
|
|
244
|
+
if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id };
|
|
220
245
|
}
|
|
221
|
-
return
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Whether `token` is a data-plane admission secret. */
|
|
250
|
+
export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
|
|
251
|
+
return resolveDataPlaneAdmissionSecret(token, config) !== null;
|
|
222
252
|
}
|
|
223
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Split an admission into the fields a log row records.
|
|
256
|
+
*
|
|
257
|
+
* `apiKeyId` is set only for a configured key. The other two kinds have no
|
|
258
|
+
* configured entry to name, and folding them into the id as sentinel strings
|
|
259
|
+
* would collide with a hand-edited entry that happens to be called `loopback` —
|
|
260
|
+
* ids are only validated as non-empty strings.
|
|
261
|
+
*/
|
|
262
|
+
export function admissionFields(admission: DataPlaneAdmission): {
|
|
263
|
+
admissionKind: DataPlaneAdmission["kind"];
|
|
264
|
+
apiKeyId?: string;
|
|
265
|
+
} {
|
|
266
|
+
return admission.kind === "configured"
|
|
267
|
+
? { admissionKind: "configured", apiKeyId: admission.keyId }
|
|
268
|
+
: { admissionKind: admission.kind };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export type ApiAuthDisposition = "required" | "accepted" | "rejected";
|
|
272
|
+
|
|
273
|
+
export interface ApiAuthMatrixRow {
|
|
274
|
+
endpoint: string;
|
|
275
|
+
bearer: ApiAuthDisposition;
|
|
276
|
+
dedicated: ApiAuthDisposition;
|
|
277
|
+
xApiKey: ApiAuthDisposition;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Which headers each data-plane endpoint actually accepts, shipped to the GUI so
|
|
282
|
+
* it stops describing the rule from memory. The dashboard has been telling users
|
|
283
|
+
* that Chat Completions takes `Authorization: Bearer`, which this file has never
|
|
284
|
+
* allowed — that route uses the dedicated-header-only wrapper because
|
|
285
|
+
* `Authorization` there may belong to Codex Direct passthrough.
|
|
286
|
+
*
|
|
287
|
+
* It lives next to the wrappers it describes, and a test drives real requests
|
|
288
|
+
* against every cell rather than reading the table back to itself.
|
|
289
|
+
*/
|
|
290
|
+
export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [
|
|
291
|
+
{ endpoint: "/v1/responses", bearer: "rejected", dedicated: "required", xApiKey: "rejected" },
|
|
292
|
+
{ endpoint: "/v1/chat/completions", bearer: "rejected", dedicated: "required", xApiKey: "rejected" },
|
|
293
|
+
{ endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
|
|
294
|
+
{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
|
|
295
|
+
];
|
|
296
|
+
|
|
224
297
|
/** Whether `token` is the environment-provided management secret. */
|
|
225
298
|
export function isManagementAdmissionSecret(token: string): boolean {
|
|
226
299
|
const actual = token.trim();
|
|
@@ -247,14 +320,23 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
|
|
|
247
320
|
if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
|
|
248
321
|
}
|
|
249
322
|
|
|
250
|
-
|
|
251
|
-
|
|
323
|
+
/**
|
|
324
|
+
* Resolving form of `hasValidApiAuth`: identical header precedence, identical
|
|
325
|
+
* decision, but it names the admission instead of collapsing it to a boolean.
|
|
326
|
+
*/
|
|
327
|
+
export function resolveApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null {
|
|
328
|
+
// A loopback bind never reads a token at all, so there is no key to name.
|
|
329
|
+
if (!isApiAuthRequired(config)) return { kind: "loopback" };
|
|
252
330
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
253
331
|
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
|
|
254
332
|
// Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
|
|
255
333
|
|| req.headers.get("x-api-key")?.trim();
|
|
256
|
-
if (!actual) return
|
|
257
|
-
return
|
|
334
|
+
if (!actual) return null;
|
|
335
|
+
return resolveDataPlaneAdmissionSecret(actual, config);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
|
|
339
|
+
return resolveApiAuth(req, config) !== null;
|
|
258
340
|
}
|
|
259
341
|
|
|
260
342
|
export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-plane"): Response | null {
|
|
@@ -267,10 +349,17 @@ export function requireApiAuth(req: Request, config: OcxConfig, _kind: "data-pla
|
|
|
267
349
|
* Codex Direct. Remote binds must use the dedicated proxy header so the two bearer
|
|
268
350
|
* domains can never be confused.
|
|
269
351
|
*/
|
|
270
|
-
export function
|
|
271
|
-
if (!isApiAuthRequired(config)) return
|
|
352
|
+
export function resolveResponsesApiAuth(req: Request, config: OcxConfig): DataPlaneAdmission | null {
|
|
353
|
+
if (!isApiAuthRequired(config)) return { kind: "loopback" };
|
|
354
|
+
// Dedicated header ONLY. `Authorization` on these transports may belong to
|
|
355
|
+
// Codex Direct passthrough, and the two bearer domains must stay unconfusable.
|
|
272
356
|
const actual = req.headers.get("x-opencodex-api-key")?.trim();
|
|
273
|
-
if (actual
|
|
357
|
+
if (!actual) return null;
|
|
358
|
+
return resolveDataPlaneAdmissionSecret(actual, config);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function requireResponsesApiAuth(req: Request, config: OcxConfig): Response | null {
|
|
362
|
+
if (resolveResponsesApiAuth(req, config)) return null;
|
|
274
363
|
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
275
364
|
}
|
|
276
365
|
|
|
@@ -418,9 +507,13 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
418
507
|
] as const) {
|
|
419
508
|
copyIfDefined(dto, provider, key);
|
|
420
509
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
510
|
+
// Resolve the note by DESTINATION, not by name. A preset saved under a custom name is
|
|
511
|
+
// still pointed at the same vendor route, and a usage restriction the user needs to see
|
|
512
|
+
// must not disappear because the row was renamed. Prefer the same-name entry so an
|
|
513
|
+
// unrenamed provider keeps its exact registry note.
|
|
514
|
+
const registryNote = (providerMatchesRegistryTransport(name, provider)
|
|
515
|
+
? getProviderRegistryEntry(name)
|
|
516
|
+
: registryEntryForProviderDestination(provider))?.note;
|
|
424
517
|
if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
|
|
425
518
|
const codexAccountMode = providerCodexAccountMode(name, provider);
|
|
426
519
|
if (codexAccountMode) dto.codexAccountMode = codexAccountMode;
|
|
@@ -31,16 +31,24 @@ import {
|
|
|
31
31
|
} from "./request-log";
|
|
32
32
|
import { responseWithDeferredRequestLog } from "./relay";
|
|
33
33
|
import { handleResponses } from "./responses";
|
|
34
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
35
|
+
import {
|
|
36
|
+
createTranslatorBudget,
|
|
37
|
+
finalizeTranslatorBudgetResponse,
|
|
38
|
+
isTranslatorBudgetExceededError,
|
|
39
|
+
type TranslatorBudget,
|
|
40
|
+
} from "../lib/translator-budget";
|
|
34
41
|
|
|
35
42
|
type Rec = Record<string, unknown>;
|
|
36
43
|
|
|
37
44
|
function isRec(v: unknown): v is Rec {
|
|
38
45
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
39
46
|
}
|
|
40
|
-
async function readChatBody(req: Request): Promise<unknown> {
|
|
47
|
+
async function readChatBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
|
|
41
48
|
try {
|
|
42
|
-
return await readJsonRequestBody(req);
|
|
49
|
+
return await readJsonRequestBody(req, budget);
|
|
43
50
|
} catch (err) {
|
|
51
|
+
if (isTranslatorBudgetExceededError(err)) throw err;
|
|
44
52
|
throw new ChatCompletionsRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
45
53
|
}
|
|
46
54
|
}
|
|
@@ -49,17 +57,43 @@ export async function handleChatCompletions(
|
|
|
49
57
|
req: Request,
|
|
50
58
|
config: OcxConfig,
|
|
51
59
|
logCtx: RequestLogContext,
|
|
52
|
-
logIds?: { requestId: string; start: number },
|
|
60
|
+
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
|
|
61
|
+
): Promise<Response> {
|
|
62
|
+
const translatorBudget = createTranslatorBudget();
|
|
63
|
+
try {
|
|
64
|
+
return finalizeTranslatorBudgetResponse(
|
|
65
|
+
await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds),
|
|
66
|
+
translatorBudget,
|
|
67
|
+
);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
translatorBudget.dispose();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function handleChatCompletionsWithBudget(
|
|
75
|
+
req: Request,
|
|
76
|
+
config: OcxConfig,
|
|
77
|
+
logCtx: RequestLogContext,
|
|
78
|
+
translatorBudget: TranslatorBudget,
|
|
79
|
+
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
|
|
53
80
|
): Promise<Response> {
|
|
54
81
|
let chatBody: unknown;
|
|
55
82
|
let internalBody: Rec;
|
|
56
83
|
try {
|
|
57
|
-
chatBody = await readChatBody(req);
|
|
84
|
+
chatBody = await readChatBody(req, translatorBudget);
|
|
58
85
|
internalBody = chatCompletionsToResponsesBody(chatBody);
|
|
86
|
+
translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
|
|
59
87
|
} catch (err) {
|
|
60
|
-
const
|
|
88
|
+
const overflow = isTranslatorBudgetExceededError(err);
|
|
89
|
+
const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
|
|
61
90
|
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
62
|
-
return chatCompletionsErrorResponse(
|
|
91
|
+
return chatCompletionsErrorResponse(
|
|
92
|
+
status,
|
|
93
|
+
overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
|
|
94
|
+
overflow ? "request_too_large" : undefined,
|
|
95
|
+
overflow ? "translation_buffer_limit" : undefined,
|
|
96
|
+
);
|
|
63
97
|
}
|
|
64
98
|
|
|
65
99
|
const requestedModel = (chatBody as Rec).model as string;
|
|
@@ -78,7 +112,7 @@ export async function handleChatCompletions(
|
|
|
78
112
|
const route = routeModel(config, internalBody.model as string);
|
|
79
113
|
// Settle the wire once so every branch below reads the adapter this model will
|
|
80
114
|
// actually use, not the provider-wide default (#404).
|
|
81
|
-
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
115
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
|
|
82
116
|
logCtx.model = route.modelId;
|
|
83
117
|
logCtx.providerAdapter = route.provider.adapter;
|
|
84
118
|
logCtx.requestedModel = requestedModel;
|
|
@@ -135,10 +169,12 @@ export async function handleChatCompletions(
|
|
|
135
169
|
/* optional */
|
|
136
170
|
}
|
|
137
171
|
|
|
172
|
+
const internalBodyJson = JSON.stringify(internalBody);
|
|
173
|
+
translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" });
|
|
138
174
|
const internalReq = new Request("http://localhost/v1/responses", {
|
|
139
175
|
method: "POST",
|
|
140
176
|
headers,
|
|
141
|
-
body:
|
|
177
|
+
body: internalBodyJson,
|
|
142
178
|
});
|
|
143
179
|
|
|
144
180
|
let nativeLogged = false;
|
|
@@ -148,7 +184,11 @@ export async function handleChatCompletions(
|
|
|
148
184
|
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
149
185
|
};
|
|
150
186
|
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
187
|
+
...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
|
|
151
188
|
abortSignal: req.signal,
|
|
189
|
+
// Body is Responses-shaped by now, but the client spoke Chat Completions.
|
|
190
|
+
inboundWire: "chat",
|
|
191
|
+
translatorBudget,
|
|
152
192
|
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
|
|
153
193
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
154
194
|
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
@@ -231,7 +271,7 @@ export async function handleChatCompletions(
|
|
|
231
271
|
|
|
232
272
|
const contentType = response.headers.get("content-type") ?? "";
|
|
233
273
|
if (contentType.includes("text/event-stream") && response.body) {
|
|
234
|
-
const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel);
|
|
274
|
+
const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel, { translatorBudget });
|
|
235
275
|
if (stream) {
|
|
236
276
|
// Stream failures surface as an error SSE frame then abort the body — never a
|
|
237
277
|
// success completion that embeds `[error] ...` + clean [DONE].
|
|
@@ -245,7 +285,7 @@ export async function handleChatCompletions(
|
|
|
245
285
|
});
|
|
246
286
|
}
|
|
247
287
|
try {
|
|
248
|
-
const completion = await collectChatCompletion(chatSse, requestedModel);
|
|
288
|
+
const completion = await collectChatCompletion(chatSse, requestedModel, translatorBudget);
|
|
249
289
|
return new Response(JSON.stringify(completion), {
|
|
250
290
|
status: 200,
|
|
251
291
|
headers: { "Content-Type": "application/json" },
|
|
@@ -274,7 +314,10 @@ export async function handleChatCompletions(
|
|
|
274
314
|
const error = (json as { error?: { message?: string; type?: string; code?: string | null } }).error;
|
|
275
315
|
const message = error?.message ?? "upstream request failed";
|
|
276
316
|
const classified = classifyError(502, error?.type ?? "server_error", message);
|
|
277
|
-
if (
|
|
317
|
+
if (error?.code === "translation_buffer_limit") {
|
|
318
|
+
classified.code = "translation_buffer_limit";
|
|
319
|
+
classified.type = "invalid_request_error";
|
|
320
|
+
} else if (isCyberPolicyCode(error?.code)) {
|
|
278
321
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
279
322
|
classified.type = "invalid_request_error";
|
|
280
323
|
} else if (error?.code === "model_not_found") {
|
|
@@ -283,7 +326,9 @@ export async function handleChatCompletions(
|
|
|
283
326
|
classified.type = "invalid_request_error";
|
|
284
327
|
}
|
|
285
328
|
return chatCompletionsErrorResponse(
|
|
286
|
-
|
|
329
|
+
classified.code === "translation_buffer_limit"
|
|
330
|
+
? 413
|
|
331
|
+
: isCyberPolicyCode(classified.code) ? 400 : 502,
|
|
287
332
|
message,
|
|
288
333
|
classified.type,
|
|
289
334
|
classified.code,
|
|
@@ -33,6 +33,13 @@ import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, typ
|
|
|
33
33
|
import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
|
|
34
34
|
import { responseWithDeferredRequestLog } from "./relay";
|
|
35
35
|
import { handleResponses } from "./responses";
|
|
36
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
37
|
+
import {
|
|
38
|
+
createTranslatorBudget,
|
|
39
|
+
finalizeTranslatorBudgetResponse,
|
|
40
|
+
isTranslatorBudgetExceededError,
|
|
41
|
+
type TranslatorBudget,
|
|
42
|
+
} from "../lib/translator-budget";
|
|
36
43
|
|
|
37
44
|
type Rec = Record<string, unknown>;
|
|
38
45
|
|
|
@@ -62,10 +69,11 @@ function claudeInboundDisabled(config: OcxConfig): Response | null {
|
|
|
62
69
|
return null;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
async function readAnthropicBody(req: Request): Promise<unknown> {
|
|
72
|
+
async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
|
|
66
73
|
try {
|
|
67
|
-
return await readJsonRequestBody(req);
|
|
74
|
+
return await readJsonRequestBody(req, budget);
|
|
68
75
|
} catch (err) {
|
|
76
|
+
if (isTranslatorBudgetExceededError(err)) throw err;
|
|
69
77
|
throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
70
78
|
}
|
|
71
79
|
}
|
|
@@ -511,7 +519,26 @@ export async function handleClaudeMessages(
|
|
|
511
519
|
req: Request,
|
|
512
520
|
config: OcxConfig,
|
|
513
521
|
logCtx: RequestLogContext,
|
|
514
|
-
logIds?: { requestId: string; start: number },
|
|
522
|
+
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
|
|
523
|
+
): Promise<Response> {
|
|
524
|
+
const translatorBudget = createTranslatorBudget();
|
|
525
|
+
try {
|
|
526
|
+
return finalizeTranslatorBudgetResponse(
|
|
527
|
+
await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds),
|
|
528
|
+
translatorBudget,
|
|
529
|
+
);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
translatorBudget.dispose();
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function handleClaudeMessagesWithBudget(
|
|
537
|
+
req: Request,
|
|
538
|
+
config: OcxConfig,
|
|
539
|
+
logCtx: RequestLogContext,
|
|
540
|
+
translatorBudget: TranslatorBudget,
|
|
541
|
+
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
|
|
515
542
|
): Promise<Response> {
|
|
516
543
|
logCtx.surface = "claude";
|
|
517
544
|
const disabled = claudeInboundDisabled(config);
|
|
@@ -525,7 +552,7 @@ export async function handleClaudeMessages(
|
|
|
525
552
|
let cacheKeySource: ClaudeCacheKeySource = null;
|
|
526
553
|
let effortOverride: ReturnType<typeof extractOcxEffortDirective> = null;
|
|
527
554
|
try {
|
|
528
|
-
anthropicBody = await readAnthropicBody(req);
|
|
555
|
+
anthropicBody = await readAnthropicBody(req, translatorBudget);
|
|
529
556
|
// Defensive [1m] strip (devlog 138): clients normally remove the context-variant
|
|
530
557
|
// marker themselves; the 1M signal we act on is the anthropic-beta header.
|
|
531
558
|
// Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
|
|
@@ -575,11 +602,18 @@ export async function handleClaudeMessages(
|
|
|
575
602
|
}
|
|
576
603
|
const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode);
|
|
577
604
|
internalBody = translation.body;
|
|
605
|
+
translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
|
|
578
606
|
cacheKeySource = translation.cacheKeySource;
|
|
579
607
|
} catch (err) {
|
|
580
|
-
const
|
|
608
|
+
const overflow = isTranslatorBudgetExceededError(err);
|
|
609
|
+
const status = overflow ? 413 : err instanceof AnthropicRequestError ? 400 : 500;
|
|
581
610
|
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
582
|
-
return anthropicErrorResponse(
|
|
611
|
+
return anthropicErrorResponse(
|
|
612
|
+
status,
|
|
613
|
+
overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
|
|
614
|
+
overflow ? "request_too_large" : undefined,
|
|
615
|
+
overflow ? "translation_buffer_limit" : undefined,
|
|
616
|
+
);
|
|
583
617
|
}
|
|
584
618
|
|
|
585
619
|
const requestedModel = (anthropicBody as Rec).model as string;
|
|
@@ -596,7 +630,7 @@ export async function handleClaudeMessages(
|
|
|
596
630
|
const route = routeModel(config, internalBody.model as string);
|
|
597
631
|
// Settle the wire once so the sampling decision below reads the effective
|
|
598
632
|
// adapter rather than the provider-wide default (#404).
|
|
599
|
-
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
633
|
+
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
|
|
600
634
|
if (route.provider.adapter === "openai-responses") {
|
|
601
635
|
nativeRoute = true;
|
|
602
636
|
delete internalBody.max_output_tokens;
|
|
@@ -666,10 +700,12 @@ export async function handleClaudeMessages(
|
|
|
666
700
|
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
|
|
667
701
|
}
|
|
668
702
|
}
|
|
703
|
+
const internalBodyJson = JSON.stringify(internalBody);
|
|
704
|
+
translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" });
|
|
669
705
|
const internalReq = new Request("http://localhost/v1/responses", {
|
|
670
706
|
method: "POST",
|
|
671
707
|
headers,
|
|
672
|
-
body:
|
|
708
|
+
body: internalBodyJson,
|
|
673
709
|
});
|
|
674
710
|
|
|
675
711
|
// Request-log wiring mirrors the /v1/responses route: native passthrough finalizes
|
|
@@ -683,8 +719,14 @@ export async function handleClaudeMessages(
|
|
|
683
719
|
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
684
720
|
};
|
|
685
721
|
const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
|
|
722
|
+
...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
|
|
686
723
|
abortSignal: req.signal,
|
|
687
724
|
promptCacheKeyIsSharedCohort: cacheKeySource === "system",
|
|
725
|
+
// The body is Responses-shaped by now, but the client spoke Anthropic Messages.
|
|
726
|
+
// Without this the replay would look native and a Responses-scoped wire default
|
|
727
|
+
// would fire, disagreeing with the pre-flight decision above.
|
|
728
|
+
inboundWire: "anthropic",
|
|
729
|
+
translatorBudget,
|
|
688
730
|
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
|
|
689
731
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
690
732
|
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
@@ -735,7 +777,7 @@ export async function handleClaudeMessages(
|
|
|
735
777
|
|
|
736
778
|
const contentType = response.headers.get("content-type") ?? "";
|
|
737
779
|
if (contentType.includes("text/event-stream") && response.body) {
|
|
738
|
-
const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel);
|
|
780
|
+
const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, { translatorBudget });
|
|
739
781
|
if (stream) {
|
|
740
782
|
return new Response(anthropicSse, {
|
|
741
783
|
status: 200,
|
|
@@ -746,8 +788,29 @@ export async function handleClaudeMessages(
|
|
|
746
788
|
},
|
|
747
789
|
});
|
|
748
790
|
}
|
|
749
|
-
|
|
791
|
+
let message: Rec;
|
|
792
|
+
try {
|
|
793
|
+
message = await collectAnthropicMessage(anthropicSse, requestedModel, translatorBudget);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (isTranslatorBudgetExceededError(error)) {
|
|
796
|
+
return anthropicErrorResponse(413, error.message, "request_too_large", error.code);
|
|
797
|
+
}
|
|
798
|
+
return anthropicErrorResponse(502, error instanceof Error ? error.message : String(error), "api_error");
|
|
799
|
+
}
|
|
750
800
|
const isError = (message as Rec).type === "error";
|
|
801
|
+
const translatedError = isError && typeof (message as Rec).error === "object"
|
|
802
|
+
? (message as { error: { code?: unknown; message?: unknown } }).error
|
|
803
|
+
: undefined;
|
|
804
|
+
if (translatedError?.code === "translation_buffer_limit") {
|
|
805
|
+
return anthropicErrorResponse(
|
|
806
|
+
413,
|
|
807
|
+
typeof translatedError.message === "string"
|
|
808
|
+
? translatedError.message
|
|
809
|
+
: "upstream translation buffer exceeded the safe limit",
|
|
810
|
+
"request_too_large",
|
|
811
|
+
"translation_buffer_limit",
|
|
812
|
+
);
|
|
813
|
+
}
|
|
751
814
|
return new Response(JSON.stringify(message), {
|
|
752
815
|
status: isError ? 502 : 200,
|
|
753
816
|
headers: { "Content-Type": "application/json" },
|
|
@@ -763,7 +826,15 @@ export async function handleClaudeMessages(
|
|
|
763
826
|
}
|
|
764
827
|
const status = (json as Rec)?.status;
|
|
765
828
|
if (status === "failed") {
|
|
766
|
-
const error = (json as { error?: { message?: string } }).error;
|
|
829
|
+
const error = (json as { error?: { message?: string; code?: string } }).error;
|
|
830
|
+
if (error?.code === "translation_buffer_limit") {
|
|
831
|
+
return anthropicErrorResponse(
|
|
832
|
+
413,
|
|
833
|
+
error.message ?? "upstream translation buffer exceeded the safe limit",
|
|
834
|
+
"request_too_large",
|
|
835
|
+
"translation_buffer_limit",
|
|
836
|
+
);
|
|
837
|
+
}
|
|
767
838
|
return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
|
|
768
839
|
}
|
|
769
840
|
const message = responsesJsonToAnthropicMessage(json, requestedModel);
|
|
@@ -800,12 +871,13 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig):
|
|
|
800
871
|
if (disabled) return disabled;
|
|
801
872
|
|
|
802
873
|
let body: unknown;
|
|
874
|
+
const translatorBudget = createTranslatorBudget();
|
|
803
875
|
try {
|
|
804
|
-
body = await readAnthropicBody(req);
|
|
876
|
+
body = await readAnthropicBody(req, translatorBudget);
|
|
805
877
|
} catch (err) {
|
|
806
878
|
if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
|
|
807
879
|
return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err));
|
|
808
|
-
}
|
|
880
|
+
} finally { translatorBudget.dispose(); }
|
|
809
881
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
810
882
|
return anthropicErrorResponse(400, "request body must be a JSON object");
|
|
811
883
|
}
|