@bitkyc08/opencodex 2.7.8 → 2.7.9-preview.20260712
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.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/gui/dist/assets/index-BcaDQD3i.js +40 -0
- package/gui/dist/assets/index-Cq8maiJf.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +11 -9
- package/src/adapters/cursor/exec-policy.ts +38 -0
- package/src/adapters/cursor/live-transport.ts +4 -3
- package/src/adapters/cursor/protobuf-request.ts +20 -0
- package/src/adapters/cursor/transport.ts +5 -0
- package/src/adapters/cursor.ts +2 -2
- package/src/bridge.ts +4 -2
- package/src/claude/agents-inject.ts +198 -0
- package/src/claude/alias.ts +69 -0
- package/src/claude/context-windows.ts +189 -0
- package/src/claude/desktop-3p.ts +254 -0
- package/src/claude/gateway-cache.ts +70 -0
- package/src/claude/inbound-debug.ts +114 -0
- package/src/claude/inbound.ts +481 -0
- package/src/claude/model-info.ts +145 -0
- package/src/claude/outbound.ts +487 -0
- package/src/cli/claude.ts +157 -0
- package/src/cli/help.ts +12 -0
- package/src/cli/index.ts +86 -7
- package/src/cli/v2.ts +23 -18
- package/src/codex/features.ts +288 -16
- package/src/lib/crash-guard.ts +11 -1
- package/src/lib/debug-settings.ts +14 -2
- package/src/lib/token-estimate.ts +27 -1
- package/src/providers/registry.ts +1 -1
- package/src/server/auth-cors.ts +4 -2
- package/src/server/claude-messages.ts +494 -0
- package/src/server/index.ts +72 -0
- package/src/server/management-api.ts +226 -34
- package/src/server/request-log.ts +19 -4
- package/src/server/responses.ts +13 -1
- package/src/server/system-env.ts +314 -0
- package/src/types.ts +108 -0
- package/src/usage/log.ts +8 -2
- package/src/usage/summary.ts +18 -1
- package/src/usage/totals.ts +7 -18
- package/gui/dist/assets/index-Bp8dDrs5.js +0 -40
- package/gui/dist/assets/index-C0xVu72_.css +0 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Usage capture: `ocx debug usage on|off|status|reset|logs [-f]` (or OPENCODEX_USAGE_DEBUG=1).
|
|
5
5
|
* Injection log: `ocx debug injection on|off|status|reset` (or OCX_INJECTION_DEBUG=1) —
|
|
6
6
|
* multi-agent guidance-injection console lines, default OFF.
|
|
7
|
+
* Claude inbound capture: `ocx debug claude on|off|status|reset` (or OCX_CLAUDE_DEBUG=1) —
|
|
8
|
+
* allowlist-scalar ring of inbound Anthropic request metadata, default OFF.
|
|
7
9
|
* `/api/debug` and `ocx debug` override env defaults without restart.
|
|
8
10
|
*/
|
|
9
11
|
|
|
@@ -11,6 +13,7 @@ export const DEBUG_ENV = {
|
|
|
11
13
|
debug: "OCX_DEBUG",
|
|
12
14
|
usage: "OPENCODEX_USAGE_DEBUG",
|
|
13
15
|
injection: "OCX_INJECTION_DEBUG",
|
|
16
|
+
claude: "OCX_CLAUDE_DEBUG",
|
|
14
17
|
} as const;
|
|
15
18
|
|
|
16
19
|
/** Legacy env var that still enables provider debug logging. */
|
|
@@ -22,6 +25,7 @@ export interface DebugSettingsView {
|
|
|
22
25
|
enabled: boolean;
|
|
23
26
|
usage: boolean;
|
|
24
27
|
injection: boolean;
|
|
28
|
+
claude: boolean;
|
|
25
29
|
runtimeOverride: Partial<Record<DebugFlag, boolean>>;
|
|
26
30
|
env: Record<DebugFlag, boolean>;
|
|
27
31
|
}
|
|
@@ -57,22 +61,30 @@ export function isInjectionDebugEnabled(): boolean {
|
|
|
57
61
|
return envFlag(DEBUG_ENV.injection);
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
/** Claude inbound request capture (default OFF; GUI toggle / API / CLI). */
|
|
65
|
+
export function isClaudeDebugEnabled(): boolean {
|
|
66
|
+
if (runtimeOverride.claude !== undefined) return runtimeOverride.claude;
|
|
67
|
+
return envFlag(DEBUG_ENV.claude);
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
export function getDebugSettings(): DebugSettingsView {
|
|
61
71
|
return {
|
|
62
72
|
enabled: isDebugEnabled(),
|
|
63
73
|
usage: isUsageDebugEnabled(),
|
|
64
74
|
injection: isInjectionDebugEnabled(),
|
|
75
|
+
claude: isClaudeDebugEnabled(),
|
|
65
76
|
runtimeOverride: { ...runtimeOverride },
|
|
66
77
|
env: {
|
|
67
78
|
debug: envFlag(DEBUG_ENV.debug) || legacyDebugEnvEnabled(),
|
|
68
79
|
usage: envFlag(DEBUG_ENV.usage),
|
|
69
80
|
injection: envFlag(DEBUG_ENV.injection),
|
|
81
|
+
claude: envFlag(DEBUG_ENV.claude),
|
|
70
82
|
},
|
|
71
83
|
};
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
export function setDebugSettings(partial: Partial<Record<DebugFlag, boolean>>): DebugSettingsView {
|
|
75
|
-
for (const key of ["debug", "usage", "injection"] as const) {
|
|
87
|
+
for (const key of ["debug", "usage", "injection", "claude"] as const) {
|
|
76
88
|
if (partial[key] !== undefined) runtimeOverride[key] = partial[key];
|
|
77
89
|
}
|
|
78
90
|
return getDebugSettings();
|
|
@@ -84,7 +96,7 @@ export function clearDebugSetting(flag: DebugFlag): DebugSettingsView {
|
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
export function clearDebugSettings(): DebugSettingsView {
|
|
87
|
-
for (const key of ["debug", "usage", "injection"] as const) {
|
|
99
|
+
for (const key of ["debug", "usage", "injection", "claude"] as const) {
|
|
88
100
|
delete runtimeOverride[key];
|
|
89
101
|
}
|
|
90
102
|
return getDebugSettings();
|
|
@@ -31,6 +31,30 @@ export function charsPerToken(modelId?: string): number {
|
|
|
31
31
|
return DEFAULT_CHARS_PER_TOKEN;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* CJK-aware ratio (devlog 260712 B3, audit R2#7): Korean/Chinese/Japanese text packs
|
|
36
|
+
* roughly one token per 1.5-3 chars, so a CJK-heavy blob estimated at English ratios
|
|
37
|
+
* badly undercounts. When >30% of chars are CJK, clamp DOWN to 2.5 chars/token —
|
|
38
|
+
* `min(model ratio, 2.5)` so per-model ratios (Claude 3.5, Kiro family) never rise.
|
|
39
|
+
*/
|
|
40
|
+
const CJK_CHARS_PER_TOKEN = 2.5;
|
|
41
|
+
const CJK_RATIO_THRESHOLD = 0.3;
|
|
42
|
+
// Hangul syllables/jamo, CJK unified ideographs (+ext A), hiragana/katakana.
|
|
43
|
+
const CJK_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u30FF]/;
|
|
44
|
+
|
|
45
|
+
function cjkRatio(text: string): number {
|
|
46
|
+
if (text.length === 0) return 0;
|
|
47
|
+
// Sample long blobs for O(1) cost: every char up to 2k, then a stride.
|
|
48
|
+
const stride = text.length > 2048 ? Math.ceil(text.length / 2048) : 1;
|
|
49
|
+
let cjk = 0;
|
|
50
|
+
let sampled = 0;
|
|
51
|
+
for (let i = 0; i < text.length; i += stride) {
|
|
52
|
+
sampled++;
|
|
53
|
+
if (CJK_RE.test(text[i]!)) cjk++;
|
|
54
|
+
}
|
|
55
|
+
return sampled === 0 ? 0 : cjk / sampled;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
/**
|
|
35
59
|
* Estimate the token count of a text blob. Pure and deterministic.
|
|
36
60
|
* Returns 0 for empty/whitespace-free-empty input; otherwise ceil(length / ratio), min 1.
|
|
@@ -39,5 +63,7 @@ export function estimateTokens(text: string, modelId?: string): number {
|
|
|
39
63
|
if (!text) return 0;
|
|
40
64
|
const len = text.length;
|
|
41
65
|
if (len === 0) return 0;
|
|
42
|
-
|
|
66
|
+
let ratio = charsPerToken(modelId);
|
|
67
|
+
if (cjkRatio(text) > CJK_RATIO_THRESHOLD) ratio = Math.min(ratio, CJK_CHARS_PER_TOKEN);
|
|
68
|
+
return Math.max(1, Math.ceil(len / ratio));
|
|
43
69
|
}
|
|
@@ -199,7 +199,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
199
199
|
authKind: "oauth",
|
|
200
200
|
featured: false,
|
|
201
201
|
dashboardPreset: true,
|
|
202
|
-
note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution stays disabled unless you set \"unsafeAllowNativeLocalExec\": true on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
|
|
202
|
+
note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution stays disabled unless you set \"nativeLocalExec\": \"on\" (always) or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
|
|
203
203
|
models: cursorModelIds(CURSOR_STATIC_MODELS),
|
|
204
204
|
liveModels: true,
|
|
205
205
|
defaultModel: "auto",
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
|
|
|
73
73
|
return {
|
|
74
74
|
"Access-Control-Allow-Origin": allowOrigin,
|
|
75
75
|
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
76
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
|
|
76
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta",
|
|
77
77
|
"Vary": "Origin",
|
|
78
78
|
};
|
|
79
79
|
}
|
|
@@ -140,7 +140,9 @@ export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolea
|
|
|
140
140
|
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
|
|
141
141
|
if (!isApiAuthRequired(config)) return true;
|
|
142
142
|
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
143
|
-
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
|
|
143
|
+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim()
|
|
144
|
+
// Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
|
|
145
|
+
|| req.headers.get("x-api-key")?.trim();
|
|
144
146
|
if (!actual) return false;
|
|
145
147
|
return isProxyAdmissionSecret(actual, config);
|
|
146
148
|
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages inbound (/v1/messages + /v1/messages/count_tokens) for Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Translate-and-replay (devlog/260711_claude_inbound/010): the Anthropic request is
|
|
5
|
+
* converted to a /v1/responses body and replayed through handleResponses on an
|
|
6
|
+
* internal Request, so routing/OAuth/account-pool/failover/sidecars are inherited
|
|
7
|
+
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
|
|
8
|
+
*/
|
|
9
|
+
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
10
|
+
import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
|
|
11
|
+
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
12
|
+
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
13
|
+
import {
|
|
14
|
+
anthropicErrorBody,
|
|
15
|
+
anthropicErrorResponse,
|
|
16
|
+
collectAnthropicMessage,
|
|
17
|
+
responsesJsonToAnthropicMessage,
|
|
18
|
+
responsesSseToAnthropicSse,
|
|
19
|
+
} from "../claude/outbound";
|
|
20
|
+
import { estimateTokens } from "../lib/token-estimate";
|
|
21
|
+
import { routeModel } from "../router";
|
|
22
|
+
import type { OcxConfig } from "../types";
|
|
23
|
+
import { readJsonRequestBody } from "./request-decompress";
|
|
24
|
+
import { addFinalRequestLog, httpStatusForTerminalStatus, type RequestLogContext, type RequestLogEntry } from "./request-log";
|
|
25
|
+
import { responseWithDeferredRequestLog } from "./relay";
|
|
26
|
+
import { handleResponses } from "./responses";
|
|
27
|
+
|
|
28
|
+
type Rec = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
function isRec(v: unknown): v is Rec {
|
|
31
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function claudeInboundDisabled(config: OcxConfig): Response | null {
|
|
35
|
+
if (config.claudeCode?.enabled === false) {
|
|
36
|
+
return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readAnthropicBody(req: Request): Promise<unknown> {
|
|
42
|
+
try {
|
|
43
|
+
return await readJsonRequestBody(req);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Native Anthropic passthrough (subscription OAuth pierce) ──────────────────────
|
|
50
|
+
// When Claude Code runs with ONLY ANTHROPIC_BASE_URL set (subscription mode — the
|
|
51
|
+
// connectors warning stays off), it sends its OWN claude.ai OAuth Bearer to us.
|
|
52
|
+
// Requests for genuine claude/anthropic models that no alias/modelMap claims are
|
|
53
|
+
// forwarded VERBATIM to api.anthropic.com with the caller's credential and all
|
|
54
|
+
// end-to-end headers, so betas/thinking signatures/billing identity stay native.
|
|
55
|
+
// (Evidence: teamclaude --no-mitm + Vercel gateway docs, devlog 003/060.)
|
|
56
|
+
|
|
57
|
+
const PASSTHROUGH_STRIP_HEADERS = new Set([
|
|
58
|
+
"connection", "keep-alive", "transfer-encoding", "upgrade", "te", "trailer",
|
|
59
|
+
"proxy-authenticate", "proxy-authorization", "host", "content-length",
|
|
60
|
+
"accept-encoding", "x-opencodex-api-key", "origin",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function hasAnthropicNativeCredential(req: Request): boolean {
|
|
64
|
+
const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
|
|
65
|
+
const apiKey = req.headers.get("x-api-key")?.trim() ?? "";
|
|
66
|
+
return bearer.startsWith("sk-ant-") || apiKey.startsWith("sk-ant-");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string {
|
|
70
|
+
if (config.claudeCode?.nativePassthrough === false) return false;
|
|
71
|
+
if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false;
|
|
72
|
+
if (!hasAnthropicNativeCredential(req)) return false;
|
|
73
|
+
// An alias or modelMap hit means the user asked for a ROUTED model: translate instead.
|
|
74
|
+
return resolveInboundModel(model, config.claudeCode) === model;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Format a 32-hex cache key as a uuid-shaped session id (version/variant nibbles forced). */
|
|
78
|
+
function uuidFromHex(hex32: string): string {
|
|
79
|
+
const h = (hex32 + "0".repeat(32)).slice(0, 32);
|
|
80
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function anthropicUsageToOcx(usage: Rec | undefined): { inputTokens: number; outputTokens: number; cachedInputTokens?: number; cacheReadInputTokens?: number; cacheCreationInputTokens?: number } | undefined {
|
|
84
|
+
if (!usage) return undefined;
|
|
85
|
+
const num = (v: unknown) => typeof v === "number" ? v : 0;
|
|
86
|
+
const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined;
|
|
87
|
+
const read = num(usage.cache_read_input_tokens);
|
|
88
|
+
const write = num(usage.cache_creation_input_tokens);
|
|
89
|
+
// Anthropic input_tokens excludes cache read/write; normalize to the canonical
|
|
90
|
+
// inclusive convention (types.ts OcxUsage / devlog 070). cached = READS only.
|
|
91
|
+
return {
|
|
92
|
+
inputTokens: num(usage.input_tokens) + read + write,
|
|
93
|
+
outputTokens: num(usage.output_tokens),
|
|
94
|
+
...(hasCache ? {
|
|
95
|
+
cachedInputTokens: read,
|
|
96
|
+
cacheReadInputTokens: read,
|
|
97
|
+
cacheCreationInputTokens: write,
|
|
98
|
+
} : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Tap an Anthropic-vocabulary SSE stream for the request log (usage + terminal). */
|
|
103
|
+
function tapAnthropicSseForLog(
|
|
104
|
+
upstream: ReadableStream<Uint8Array>,
|
|
105
|
+
logCtx: RequestLogContext,
|
|
106
|
+
finalize: (status: number, meta: { closeReason: "terminal" | "client_cancel" }) => void,
|
|
107
|
+
): ReadableStream<Uint8Array> {
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let buffer = "";
|
|
110
|
+
let usageAcc: Rec = {};
|
|
111
|
+
const inspect = (chunk: Uint8Array) => {
|
|
112
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
113
|
+
let sep: number;
|
|
114
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
115
|
+
const frame = buffer.slice(0, sep);
|
|
116
|
+
buffer = buffer.slice(sep + 2);
|
|
117
|
+
const dataLine = frame.split("\n").filter(l => l.startsWith("data: ")).map(l => l.slice(6)).join("");
|
|
118
|
+
if (!dataLine) continue;
|
|
119
|
+
let data: unknown;
|
|
120
|
+
try { data = JSON.parse(dataLine); } catch { continue; }
|
|
121
|
+
if (!isRec(data)) continue;
|
|
122
|
+
if (data.type === "message_start" && isRec(data.message) && isRec(data.message.usage)) {
|
|
123
|
+
usageAcc = { ...usageAcc, ...data.message.usage };
|
|
124
|
+
} else if (data.type === "message_delta" && isRec(data.usage)) {
|
|
125
|
+
usageAcc = { ...usageAcc, ...data.usage };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const reader = upstream.getReader();
|
|
130
|
+
return new ReadableStream<Uint8Array>({
|
|
131
|
+
async pull(controller) {
|
|
132
|
+
try {
|
|
133
|
+
const { done, value } = await reader.read();
|
|
134
|
+
if (done) {
|
|
135
|
+
logCtx.usage = anthropicUsageToOcx(Object.keys(usageAcc).length > 0 ? usageAcc : undefined);
|
|
136
|
+
finalize(200, { closeReason: "terminal" });
|
|
137
|
+
controller.close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
inspect(value);
|
|
141
|
+
controller.enqueue(value);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
finalize(200, { closeReason: "terminal" });
|
|
144
|
+
try { controller.error(err); } catch { /* torn down */ }
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
cancel(reason) {
|
|
148
|
+
finalize(499, { closeReason: "client_cancel" });
|
|
149
|
+
reader.cancel(reason).catch(() => {});
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function anthropicNativePassthrough(
|
|
155
|
+
req: Request,
|
|
156
|
+
config: OcxConfig,
|
|
157
|
+
logCtx: RequestLogContext,
|
|
158
|
+
logIds: { requestId: string; start: number } | undefined,
|
|
159
|
+
body: Rec,
|
|
160
|
+
pathname: string,
|
|
161
|
+
): Promise<Response> {
|
|
162
|
+
const model = typeof body.model === "string" ? body.model : "unknown";
|
|
163
|
+
logCtx.model = model;
|
|
164
|
+
logCtx.provider = "anthropic-native";
|
|
165
|
+
logCtx.requestedModel = model;
|
|
166
|
+
let logged = false;
|
|
167
|
+
const finalize = (status: number, meta: { closeReason: "terminal" | "client_cancel" | "non_stream" }) => {
|
|
168
|
+
if (!logIds || logged) return;
|
|
169
|
+
logged = true;
|
|
170
|
+
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
|
|
174
|
+
const search = new URL(req.url).search;
|
|
175
|
+
const headers = new Headers();
|
|
176
|
+
req.headers.forEach((value, name) => {
|
|
177
|
+
if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
|
|
178
|
+
});
|
|
179
|
+
headers.set("content-type", "application/json");
|
|
180
|
+
|
|
181
|
+
let upstream: Response;
|
|
182
|
+
try {
|
|
183
|
+
upstream = await fetch(`${base}${pathname}${search}`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers,
|
|
186
|
+
body: JSON.stringify(body),
|
|
187
|
+
signal: req.signal,
|
|
188
|
+
});
|
|
189
|
+
} catch (err) {
|
|
190
|
+
finalize(502, { closeReason: "non_stream" });
|
|
191
|
+
return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
195
|
+
if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
|
|
196
|
+
return new Response(tapAnthropicSseForLog(upstream.body, logCtx, finalize), {
|
|
197
|
+
status: upstream.status,
|
|
198
|
+
headers: {
|
|
199
|
+
"Content-Type": contentType,
|
|
200
|
+
"Cache-Control": "no-cache",
|
|
201
|
+
"Connection": "keep-alive",
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Non-stream (count_tokens, errors, stream:false): relay verbatim, log on the spot.
|
|
206
|
+
const text = await upstream.text();
|
|
207
|
+
if (upstream.ok) {
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(text) as { usage?: Rec };
|
|
210
|
+
if (isRec(parsed?.usage)) logCtx.usage = anthropicUsageToOcx(parsed.usage);
|
|
211
|
+
} catch { /* count_tokens etc. */ }
|
|
212
|
+
}
|
|
213
|
+
finalize(upstream.status, { closeReason: "non_stream" });
|
|
214
|
+
const retryAfter = upstream.headers.get("retry-after");
|
|
215
|
+
return new Response(text, {
|
|
216
|
+
status: upstream.status,
|
|
217
|
+
headers: { "Content-Type": contentType, ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function handleClaudeMessages(
|
|
222
|
+
req: Request,
|
|
223
|
+
config: OcxConfig,
|
|
224
|
+
logCtx: RequestLogContext,
|
|
225
|
+
logIds?: { requestId: string; start: number },
|
|
226
|
+
): Promise<Response> {
|
|
227
|
+
logCtx.surface = "claude";
|
|
228
|
+
const disabled = claudeInboundDisabled(config);
|
|
229
|
+
if (disabled) {
|
|
230
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
|
|
231
|
+
return disabled;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let anthropicBody: unknown;
|
|
235
|
+
let internalBody: Rec;
|
|
236
|
+
let cacheKeySource: ClaudeCacheKeySource = null;
|
|
237
|
+
try {
|
|
238
|
+
anthropicBody = await readAnthropicBody(req);
|
|
239
|
+
// Defensive [1m] strip (devlog 138): clients normally remove the context-variant
|
|
240
|
+
// marker themselves; the 1M signal we act on is the anthropic-beta header.
|
|
241
|
+
// Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
|
|
242
|
+
if (isRec(anthropicBody) && typeof anthropicBody.model === "string") {
|
|
243
|
+
anthropicBody.model = stripOneMillionMarker(anthropicBody.model);
|
|
244
|
+
}
|
|
245
|
+
// ocx-route override (devlog 072): injected agent bodies pin their model via a
|
|
246
|
+
// system-prompt directive because 2.1.207 ignores custom ids in agent
|
|
247
|
+
// frontmatter. Must run BEFORE the native-passthrough branch — the CLI sends
|
|
248
|
+
// these subagent turns under a fallback claude model id.
|
|
249
|
+
if (isRec(anthropicBody)) {
|
|
250
|
+
const routeOverride = extractOcxRouteDirective(anthropicBody);
|
|
251
|
+
if (routeOverride && typeof anthropicBody.model === "string") {
|
|
252
|
+
anthropicBody.model = stripOneMillionMarker(routeOverride);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so
|
|
256
|
+
// native, routed, and disabled-alias paths are all observable (devlog 130 B1).
|
|
257
|
+
captureClaudeInbound(
|
|
258
|
+
"messages",
|
|
259
|
+
anthropicBody,
|
|
260
|
+
isRec(anthropicBody) && typeof anthropicBody.model === "string"
|
|
261
|
+
? resolveInboundModel(anthropicBody.model, config.claudeCode)
|
|
262
|
+
: undefined,
|
|
263
|
+
req.headers.get("anthropic-beta") ?? undefined,
|
|
264
|
+
);
|
|
265
|
+
if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
|
|
266
|
+
return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
|
|
267
|
+
}
|
|
268
|
+
const translation = anthropicToResponsesTranslation(anthropicBody, config.claudeCode);
|
|
269
|
+
internalBody = translation.body;
|
|
270
|
+
cacheKeySource = translation.cacheKeySource;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const status = err instanceof AnthropicRequestError ? 400 : 500;
|
|
273
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
274
|
+
return anthropicErrorResponse(status, err instanceof Error ? err.message : String(err));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const requestedModel = (anthropicBody as Rec).model as string;
|
|
278
|
+
const stream = internalBody.stream === true;
|
|
279
|
+
// Routed adapters only support streamed turns; always stream internally and fold
|
|
280
|
+
// the translated Anthropic SSE into a message JSON for non-streaming clients.
|
|
281
|
+
internalBody.stream = true;
|
|
282
|
+
|
|
283
|
+
// Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped
|
|
284
|
+
// bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
|
|
285
|
+
// verified live 2026-07-11). Strip them for that route; routed providers keep them.
|
|
286
|
+
let nativeRoute = false;
|
|
287
|
+
try {
|
|
288
|
+
const route = routeModel(config, internalBody.model as string);
|
|
289
|
+
if (route.provider.adapter === "openai-responses") {
|
|
290
|
+
nativeRoute = true;
|
|
291
|
+
delete internalBody.max_output_tokens;
|
|
292
|
+
delete internalBody.temperature;
|
|
293
|
+
delete internalBody.top_p;
|
|
294
|
+
delete internalBody.stop;
|
|
295
|
+
delete internalBody.user;
|
|
296
|
+
}
|
|
297
|
+
// Estimated-usage adapters (cursor/kiro) report no per-turn input tokens; stash a
|
|
298
|
+
// request-side estimate so the log's in:0 rows get a floor. NEVER set this for
|
|
299
|
+
// accurate-usage adapters — the request-log merge is max(reported, estimate) and
|
|
300
|
+
// would overwrite real usage (audit 133 R1#7).
|
|
301
|
+
if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
|
|
302
|
+
const raw = anthropicBody as Rec;
|
|
303
|
+
const parts: string[] = [];
|
|
304
|
+
if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
|
|
305
|
+
if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
|
|
306
|
+
if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
|
|
307
|
+
logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel));
|
|
308
|
+
}
|
|
309
|
+
// Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
|
|
310
|
+
// every routed model look like a reasoning model to Claude clients, so a forced
|
|
311
|
+
// effort (CLAUDE_CODE_ALWAYS_ENABLE_EFFORT) would leak reasoning params to routes
|
|
312
|
+
// that affirmatively expose NO effort control. Strip only on a definitive [] from
|
|
313
|
+
// supportedLadderFor; unknown (undefined) passes through untouched.
|
|
314
|
+
if (internalBody.reasoning !== undefined) {
|
|
315
|
+
const { supportedLadderFor } = await import("./effort-policy");
|
|
316
|
+
const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId });
|
|
317
|
+
if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning;
|
|
318
|
+
}
|
|
319
|
+
} catch { /* unknown model: let handleResponses shape the 404 */ }
|
|
320
|
+
|
|
321
|
+
const headers = new Headers({ "content-type": "application/json" });
|
|
322
|
+
for (const name of FORWARD_HEADERS) {
|
|
323
|
+
// The caller's bearer is the proxy admission token (ocx claude placeholder), never a
|
|
324
|
+
// ChatGPT credential — forwarding it upstream turns into {"detail":"Unauthorized"}.
|
|
325
|
+
if (name === "authorization") continue;
|
|
326
|
+
const value = req.headers.get(name);
|
|
327
|
+
if (value) headers.set(name, value);
|
|
328
|
+
}
|
|
329
|
+
if (nativeRoute) {
|
|
330
|
+
// No forwarded ChatGPT auth exists on this surface. Attach the main codex login
|
|
331
|
+
// (read-only auth.json token); account-pool rotation still overrides downstream.
|
|
332
|
+
const { getMainAccountToken } = await import("../codex/main-account");
|
|
333
|
+
const token = getMainAccountToken();
|
|
334
|
+
if (token) {
|
|
335
|
+
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
336
|
+
headers.set("chatgpt-account-id", token.chatgptAccountId);
|
|
337
|
+
}
|
|
338
|
+
// ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
|
|
339
|
+
// clients always send their session uuid; devlog 090 follow-up: body-level
|
|
340
|
+
// prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
|
|
341
|
+
// the header, so synthesize a stable per-session uuid from the same cache key —
|
|
342
|
+
// but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
|
|
343
|
+
// key is shared across Desktop conversations, and a shared session_id's backend
|
|
344
|
+
// semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
|
|
345
|
+
if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") {
|
|
346
|
+
headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const internalReq = new Request("http://localhost/v1/responses", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers,
|
|
352
|
+
body: JSON.stringify(internalBody),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Request-log wiring mirrors the /v1/responses route: native passthrough finalizes
|
|
356
|
+
// via the terminal callbacks; routed streams get the Responses-vocabulary log tap
|
|
357
|
+
// BEFORE translation (the translated Anthropic stream has no response.completed
|
|
358
|
+
// frame, so tapping it records a bogus 502 with no usage/cache detail).
|
|
359
|
+
let nativeLogged = false;
|
|
360
|
+
const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" }) => {
|
|
361
|
+
if (!logIds || nativeLogged) return;
|
|
362
|
+
nativeLogged = true;
|
|
363
|
+
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
364
|
+
};
|
|
365
|
+
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
366
|
+
abortSignal: req.signal,
|
|
367
|
+
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
368
|
+
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
369
|
+
});
|
|
370
|
+
const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream;
|
|
371
|
+
|
|
372
|
+
if (!response.ok) {
|
|
373
|
+
// Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status.
|
|
374
|
+
let message = `upstream error (${response.status})`;
|
|
375
|
+
try {
|
|
376
|
+
const text = await response.text();
|
|
377
|
+
try {
|
|
378
|
+
const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string };
|
|
379
|
+
const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined;
|
|
380
|
+
const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
|
|
381
|
+
message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message);
|
|
382
|
+
} catch {
|
|
383
|
+
if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
|
|
384
|
+
}
|
|
385
|
+
} catch { /* keep fallback message */ }
|
|
386
|
+
const retryAfter = response.headers.get("retry-after");
|
|
387
|
+
const out = new Response(JSON.stringify(anthropicErrorBody(response.status, message)), {
|
|
388
|
+
status: response.status,
|
|
389
|
+
headers: { "Content-Type": "application/json", ...(retryAfter ? { "Retry-After": retryAfter } : {}) },
|
|
390
|
+
});
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
395
|
+
if (contentType.includes("text/event-stream") && response.body) {
|
|
396
|
+
const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel);
|
|
397
|
+
if (stream) {
|
|
398
|
+
return new Response(anthropicSse, {
|
|
399
|
+
status: 200,
|
|
400
|
+
headers: {
|
|
401
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
402
|
+
"Cache-Control": "no-cache",
|
|
403
|
+
"Connection": "keep-alive",
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const message = await collectAnthropicMessage(anthropicSse, requestedModel);
|
|
408
|
+
const isError = (message as Rec).type === "error";
|
|
409
|
+
return new Response(JSON.stringify(message), {
|
|
410
|
+
status: isError ? 502 : 200,
|
|
411
|
+
headers: { "Content-Type": "application/json" },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Defensive: some passthrough paths may answer JSON despite stream:true.
|
|
416
|
+
let json: unknown;
|
|
417
|
+
try {
|
|
418
|
+
json = await response.json();
|
|
419
|
+
} catch {
|
|
420
|
+
return anthropicErrorResponse(502, "internal replay returned a non-JSON response", "api_error");
|
|
421
|
+
}
|
|
422
|
+
const status = (json as Rec)?.status;
|
|
423
|
+
if (status === "failed") {
|
|
424
|
+
const error = (json as { error?: { message?: string } }).error;
|
|
425
|
+
return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error");
|
|
426
|
+
}
|
|
427
|
+
const message = responsesJsonToAnthropicMessage(json, requestedModel);
|
|
428
|
+
if (!stream) {
|
|
429
|
+
return new Response(JSON.stringify(message), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
430
|
+
}
|
|
431
|
+
// Streaming client + JSON upstream: synthesize a minimal valid Anthropic stream.
|
|
432
|
+
const encoder = new TextEncoder();
|
|
433
|
+
const frames: string[] = [];
|
|
434
|
+
const emit = (name: string, data: Rec) => frames.push(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
435
|
+
emit("message_start", { type: "message_start", message: { ...message, content: [], stop_reason: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
436
|
+
const blocks = Array.isArray((message as Rec).content) ? (message as Rec).content as Rec[] : [];
|
|
437
|
+
blocks.forEach((block, index) => {
|
|
438
|
+
emit("content_block_start", { type: "content_block_start", index, content_block: block });
|
|
439
|
+
emit("content_block_stop", { type: "content_block_stop", index });
|
|
440
|
+
});
|
|
441
|
+
emit("message_delta", { type: "message_delta", delta: { stop_reason: (message as Rec).stop_reason ?? "end_turn", stop_sequence: null }, usage: (message as Rec).usage ?? {} });
|
|
442
|
+
emit("message_stop", { type: "message_stop" });
|
|
443
|
+
return new Response(encoder.encode(frames.join("")), {
|
|
444
|
+
status: 200,
|
|
445
|
+
headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Documented approximation: serialize system+messages+tools, run the char estimator. */
|
|
450
|
+
export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise<Response> {
|
|
451
|
+
const disabled = claudeInboundDisabled(config);
|
|
452
|
+
if (disabled) return disabled;
|
|
453
|
+
|
|
454
|
+
let body: unknown;
|
|
455
|
+
try {
|
|
456
|
+
body = await readAnthropicBody(req);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
|
|
459
|
+
return anthropicErrorResponse(500, err instanceof Error ? err.message : String(err));
|
|
460
|
+
}
|
|
461
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
462
|
+
return anthropicErrorResponse(400, "request body must be a JSON object");
|
|
463
|
+
}
|
|
464
|
+
const raw = body as Rec;
|
|
465
|
+
if (typeof raw.model !== "string" || raw.model.length === 0) {
|
|
466
|
+
return anthropicErrorResponse(400, "model is required");
|
|
467
|
+
}
|
|
468
|
+
let model = raw.model;
|
|
469
|
+
// Case-insensitive [1m] strip (audit 021 #7 — the CLI matches /\[1m\]/i).
|
|
470
|
+
const stripped = stripOneMillionMarker(model);
|
|
471
|
+
if (stripped !== model) {
|
|
472
|
+
model = stripped;
|
|
473
|
+
raw.model = model;
|
|
474
|
+
}
|
|
475
|
+
// ocx-route override (devlog 072): keep count_tokens consistent with messages.
|
|
476
|
+
const countRoute = extractOcxRouteDirective(raw);
|
|
477
|
+
if (countRoute) {
|
|
478
|
+
model = stripOneMillionMarker(countRoute);
|
|
479
|
+
raw.model = model;
|
|
480
|
+
}
|
|
481
|
+
captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined);
|
|
482
|
+
if (wantsNativePassthrough(req, config, model)) {
|
|
483
|
+
return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens");
|
|
484
|
+
}
|
|
485
|
+
const parts: string[] = [];
|
|
486
|
+
if (raw.system !== undefined) parts.push(typeof raw.system === "string" ? raw.system : JSON.stringify(raw.system));
|
|
487
|
+
if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages));
|
|
488
|
+
if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools));
|
|
489
|
+
const inputTokens = Math.max(1, estimateTokens(parts.join("\n"), model));
|
|
490
|
+
return new Response(JSON.stringify({ input_tokens: inputTokens }), {
|
|
491
|
+
status: 200,
|
|
492
|
+
headers: { "Content-Type": "application/json" },
|
|
493
|
+
});
|
|
494
|
+
}
|