@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
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code outbound: internal /v1/responses output -> Anthropic Messages API shapes.
|
|
3
|
+
*
|
|
4
|
+
* Wire contract pinned in devlog/260711_claude_inbound/003_evidence.md (all Tier 2):
|
|
5
|
+
* - SSE order: message_start -> (content_block_start -> deltas -> content_block_stop)*
|
|
6
|
+
* -> message_delta -> message_stop; any number of `ping`.
|
|
7
|
+
* - thinking blocks get thinking_delta(s) then ONE synthetic signature_delta just
|
|
8
|
+
* before content_block_stop (CCR precedent: Claude Code does not verify signatures).
|
|
9
|
+
* - message_delta.usage is cumulative; message_start embeds a full message snapshot.
|
|
10
|
+
* - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type Rec = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
function isRec(v: unknown): v is Rec {
|
|
16
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function uuid(): string {
|
|
20
|
+
return crypto.randomUUID().replace(/-/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** HTTP status -> Anthropic error taxonomy (010 amendment #4; full official table per devlog 100). */
|
|
24
|
+
export function anthropicErrorType(status: number): string {
|
|
25
|
+
switch (status) {
|
|
26
|
+
case 400: return "invalid_request_error";
|
|
27
|
+
case 401: return "authentication_error";
|
|
28
|
+
case 402: return "billing_error";
|
|
29
|
+
case 403: return "permission_error";
|
|
30
|
+
case 404: return "not_found_error";
|
|
31
|
+
case 409: return "conflict_error";
|
|
32
|
+
case 413: return "request_too_large";
|
|
33
|
+
case 429: return "rate_limit_error";
|
|
34
|
+
case 504: return "timeout_error";
|
|
35
|
+
case 529: return "overloaded_error";
|
|
36
|
+
default: return status >= 500 ? "api_error" : "invalid_request_error";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function anthropicErrorBody(status: number, message: string, type?: string): Rec {
|
|
41
|
+
return { type: "error", error: { type: type ?? anthropicErrorType(status), message } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function anthropicErrorResponse(status: number, message: string, type?: string): Response {
|
|
45
|
+
return new Response(JSON.stringify(anthropicErrorBody(status, message, type)), {
|
|
46
|
+
status,
|
|
47
|
+
headers: { "Content-Type": "application/json" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Responses usage -> Anthropic usage. Responses `input_tokens` is INCLUSIVE of cache
|
|
53
|
+
* read+write (types.ts convention); Anthropic `input_tokens` excludes both, so
|
|
54
|
+
* subtract the full cache detail (devlog 070 — subtracting reads only inflated the
|
|
55
|
+
* non-cached input Claude Code displays by the write share).
|
|
56
|
+
*/
|
|
57
|
+
export function anthropicUsage(usage: unknown): Rec {
|
|
58
|
+
const u = isRec(usage) ? usage : {};
|
|
59
|
+
const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
|
|
60
|
+
const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
|
|
61
|
+
const cacheWrite = typeof details.cache_write_tokens === "number" ? details.cache_write_tokens : 0;
|
|
62
|
+
const input = typeof u.input_tokens === "number" ? u.input_tokens : 0;
|
|
63
|
+
const output = typeof u.output_tokens === "number" ? u.output_tokens : 0;
|
|
64
|
+
return {
|
|
65
|
+
input_tokens: Math.max(0, input - cached - cacheWrite),
|
|
66
|
+
output_tokens: output,
|
|
67
|
+
cache_read_input_tokens: cached,
|
|
68
|
+
cache_creation_input_tokens: cacheWrite,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sseFrame(name: string, data: Rec): string {
|
|
73
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function messageSnapshot(model: string): Rec {
|
|
77
|
+
return {
|
|
78
|
+
id: `msg_${uuid()}`,
|
|
79
|
+
type: "message",
|
|
80
|
+
role: "assistant",
|
|
81
|
+
content: [],
|
|
82
|
+
model,
|
|
83
|
+
stop_reason: null,
|
|
84
|
+
stop_sequence: null,
|
|
85
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface OpenBlock {
|
|
90
|
+
kind: "text" | "thinking" | "tool_use";
|
|
91
|
+
index: number;
|
|
92
|
+
/** Responses item_id (tool calls) so output_item.done can match. */
|
|
93
|
+
itemId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
|
|
97
|
+
export function responsesSseToAnthropicSse(
|
|
98
|
+
upstream: ReadableStream<Uint8Array>,
|
|
99
|
+
model: string,
|
|
100
|
+
opts?: { pingIntervalMs?: number },
|
|
101
|
+
): ReadableStream<Uint8Array> {
|
|
102
|
+
const pingIntervalMs = opts?.pingIntervalMs ?? 20_000;
|
|
103
|
+
const decoder = new TextDecoder();
|
|
104
|
+
const encoder = new TextEncoder();
|
|
105
|
+
let buffer = "";
|
|
106
|
+
let started = false;
|
|
107
|
+
let terminated = false;
|
|
108
|
+
let blockIndex = 0;
|
|
109
|
+
let open: OpenBlock | null = null;
|
|
110
|
+
let sawToolUse = false;
|
|
111
|
+
let pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
112
|
+
|
|
113
|
+
return new ReadableStream<Uint8Array>({
|
|
114
|
+
async start(controller) {
|
|
115
|
+
const emit = (name: string, data: Rec) => controller.enqueue(encoder.encode(sseFrame(name, data)));
|
|
116
|
+
const ensureStarted = () => {
|
|
117
|
+
if (started) return;
|
|
118
|
+
started = true;
|
|
119
|
+
emit("message_start", { type: "message_start", message: messageSnapshot(model) });
|
|
120
|
+
emit("ping", { type: "ping" });
|
|
121
|
+
};
|
|
122
|
+
// Idle keepalive (devlog 100): real Anthropic streams may interleave pings anywhere;
|
|
123
|
+
// synthesizing one during upstream silence protects remote deployments behind
|
|
124
|
+
// LB/NAT idle timeouts and covers slow first tokens. Cheap and spec-legal.
|
|
125
|
+
if (pingIntervalMs > 0) {
|
|
126
|
+
pingTimer = setInterval(() => {
|
|
127
|
+
if (terminated) return;
|
|
128
|
+
try {
|
|
129
|
+
ensureStarted();
|
|
130
|
+
emit("ping", { type: "ping" });
|
|
131
|
+
} catch { /* controller torn down; the read loop is ending anyway */ }
|
|
132
|
+
}, pingIntervalMs);
|
|
133
|
+
}
|
|
134
|
+
const closeOpenBlock = () => {
|
|
135
|
+
if (!open) return;
|
|
136
|
+
if (open.kind === "thinking") {
|
|
137
|
+
// Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway.
|
|
138
|
+
emit("content_block_delta", {
|
|
139
|
+
type: "content_block_delta", index: open.index,
|
|
140
|
+
delta: { type: "signature_delta", signature: `ocx${Date.now()}` },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
emit("content_block_stop", { type: "content_block_stop", index: open.index });
|
|
144
|
+
open = null;
|
|
145
|
+
};
|
|
146
|
+
const ensureBlock = (kind: "text" | "thinking") => {
|
|
147
|
+
ensureStarted();
|
|
148
|
+
if (open && open.kind === kind) return;
|
|
149
|
+
closeOpenBlock();
|
|
150
|
+
const index = blockIndex++;
|
|
151
|
+
const contentBlock: Rec = kind === "text"
|
|
152
|
+
? { type: "text", text: "" }
|
|
153
|
+
: { type: "thinking", thinking: "", signature: "" };
|
|
154
|
+
emit("content_block_start", { type: "content_block_start", index, content_block: contentBlock });
|
|
155
|
+
open = { kind, index };
|
|
156
|
+
};
|
|
157
|
+
const finish = (stopReason: string, usage: unknown) => {
|
|
158
|
+
if (terminated) return;
|
|
159
|
+
terminated = true;
|
|
160
|
+
ensureStarted();
|
|
161
|
+
closeOpenBlock();
|
|
162
|
+
emit("message_delta", {
|
|
163
|
+
type: "message_delta",
|
|
164
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
165
|
+
usage: anthropicUsage(usage),
|
|
166
|
+
});
|
|
167
|
+
emit("message_stop", { type: "message_stop" });
|
|
168
|
+
};
|
|
169
|
+
const fail = (status: number, message: string) => {
|
|
170
|
+
if (terminated) return;
|
|
171
|
+
terminated = true;
|
|
172
|
+
ensureStarted();
|
|
173
|
+
closeOpenBlock();
|
|
174
|
+
emit("error", anthropicErrorBody(status, message));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleFrame = (eventName: string, data: Rec) => {
|
|
178
|
+
switch (eventName) {
|
|
179
|
+
case "response.created":
|
|
180
|
+
ensureStarted();
|
|
181
|
+
break;
|
|
182
|
+
case "response.heartbeat":
|
|
183
|
+
ensureStarted();
|
|
184
|
+
emit("ping", { type: "ping" });
|
|
185
|
+
break;
|
|
186
|
+
case "response.output_text.delta": {
|
|
187
|
+
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
188
|
+
ensureBlock("text");
|
|
189
|
+
emit("content_block_delta", {
|
|
190
|
+
type: "content_block_delta", index: open!.index,
|
|
191
|
+
delta: { type: "text_delta", text: data.delta },
|
|
192
|
+
});
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "response.reasoning_summary_text.delta":
|
|
196
|
+
case "response.reasoning_text.delta": {
|
|
197
|
+
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
198
|
+
ensureBlock("thinking");
|
|
199
|
+
emit("content_block_delta", {
|
|
200
|
+
type: "content_block_delta", index: open!.index,
|
|
201
|
+
delta: { type: "thinking_delta", thinking: data.delta },
|
|
202
|
+
});
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "response.output_item.added": {
|
|
206
|
+
const item = isRec(data.item) ? data.item : null;
|
|
207
|
+
if (!item || item.type !== "function_call") break;
|
|
208
|
+
ensureStarted();
|
|
209
|
+
closeOpenBlock();
|
|
210
|
+
sawToolUse = true;
|
|
211
|
+
const index = blockIndex++;
|
|
212
|
+
emit("content_block_start", {
|
|
213
|
+
type: "content_block_start", index,
|
|
214
|
+
content_block: {
|
|
215
|
+
type: "tool_use",
|
|
216
|
+
id: typeof item.call_id === "string" ? item.call_id : `toolu_${uuid()}`,
|
|
217
|
+
name: typeof item.name === "string" ? item.name : "",
|
|
218
|
+
input: {},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
open = { kind: "tool_use", index, itemId: typeof item.id === "string" ? item.id : undefined };
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "response.function_call_arguments.delta": {
|
|
225
|
+
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
226
|
+
if (!open || open.kind !== "tool_use") break;
|
|
227
|
+
emit("content_block_delta", {
|
|
228
|
+
type: "content_block_delta", index: open.index,
|
|
229
|
+
delta: { type: "input_json_delta", partial_json: data.delta },
|
|
230
|
+
});
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case "response.output_item.done": {
|
|
234
|
+
const item = isRec(data.item) ? data.item : null;
|
|
235
|
+
if (!open || !item) break;
|
|
236
|
+
// Close the matching open block (message/reasoning items close implicitly on
|
|
237
|
+
// the next block; function_call items must close here so tool input parses).
|
|
238
|
+
if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
|
|
239
|
+
else if (open.kind === "text" && item.type === "message") closeOpenBlock();
|
|
240
|
+
else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock();
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case "response.completed": {
|
|
244
|
+
const response = isRec(data.response) ? data.response : {};
|
|
245
|
+
finish(sawToolUse ? "tool_use" : "end_turn", response.usage);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "response.incomplete": {
|
|
249
|
+
const response = isRec(data.response) ? data.response : {};
|
|
250
|
+
const details = isRec(response.incomplete_details) ? response.incomplete_details : {};
|
|
251
|
+
const reason = details.reason === "max_output_tokens" ? "max_tokens"
|
|
252
|
+
: details.reason === "content_filter" ? "refusal"
|
|
253
|
+
: sawToolUse ? "tool_use" : "end_turn";
|
|
254
|
+
finish(reason, response.usage);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "response.failed": {
|
|
258
|
+
const response = isRec(data.response) ? data.response : {};
|
|
259
|
+
const error = isRec(response.error) ? response.error : {};
|
|
260
|
+
const message = typeof error.message === "string" ? error.message : "upstream request failed";
|
|
261
|
+
const status = typeof error.status === "number" ? error.status : 500;
|
|
262
|
+
fail(status, message);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
default:
|
|
266
|
+
break; // web_search_call / custom_tool_call / content_part frames: ignored v1
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const reader = upstream.getReader();
|
|
271
|
+
try {
|
|
272
|
+
for (;;) {
|
|
273
|
+
const { done, value } = await reader.read();
|
|
274
|
+
if (done) break;
|
|
275
|
+
buffer += decoder.decode(value, { stream: true });
|
|
276
|
+
let sep: number;
|
|
277
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
278
|
+
const rawFrame = buffer.slice(0, sep);
|
|
279
|
+
buffer = buffer.slice(sep + 2);
|
|
280
|
+
let eventName = "";
|
|
281
|
+
let dataLine = "";
|
|
282
|
+
for (const line of rawFrame.split("\n")) {
|
|
283
|
+
if (line.startsWith("event: ")) eventName = line.slice(7).trim();
|
|
284
|
+
else if (line.startsWith("data: ")) dataLine += line.slice(6);
|
|
285
|
+
}
|
|
286
|
+
if (!eventName || !dataLine) continue;
|
|
287
|
+
let data: unknown;
|
|
288
|
+
try { data = JSON.parse(dataLine); } catch { continue; }
|
|
289
|
+
if (!isRec(data)) continue;
|
|
290
|
+
if (terminated) continue;
|
|
291
|
+
handleFrame(eventName, data);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
|
|
295
|
+
// gateways that close such streams politely hand Claude Code an empty/partial
|
|
296
|
+
// turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
|
|
297
|
+
// with a mid-stream Anthropic error event so the client can retry.
|
|
298
|
+
fail(502, "upstream stream ended before a terminal frame (truncated response)");
|
|
299
|
+
} catch (err) {
|
|
300
|
+
fail(500, err instanceof Error ? err.message : String(err));
|
|
301
|
+
} finally {
|
|
302
|
+
if (pingTimer !== undefined) clearInterval(pingTimer);
|
|
303
|
+
reader.releaseLock();
|
|
304
|
+
controller.close();
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
cancel(reason) {
|
|
308
|
+
if (pingTimer !== undefined) clearInterval(pingTimer);
|
|
309
|
+
return upstream.cancel(reason);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Non-streaming: /v1/responses JSON -> Anthropic message JSON. */
|
|
315
|
+
export function responsesJsonToAnthropicMessage(json: unknown, model: string): Rec {
|
|
316
|
+
const body = isRec(json) ? json : {};
|
|
317
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
318
|
+
const content: Rec[] = [];
|
|
319
|
+
let sawToolUse = false;
|
|
320
|
+
|
|
321
|
+
for (const raw of output) {
|
|
322
|
+
if (!isRec(raw)) continue;
|
|
323
|
+
switch (raw.type) {
|
|
324
|
+
case "message": {
|
|
325
|
+
if (!Array.isArray(raw.content)) break;
|
|
326
|
+
for (const part of raw.content) {
|
|
327
|
+
if (isRec(part) && part.type === "output_text" && typeof part.text === "string" && part.text.length > 0) {
|
|
328
|
+
content.push({ type: "text", text: part.text });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "reasoning": {
|
|
334
|
+
const parts: string[] = [];
|
|
335
|
+
if (Array.isArray(raw.summary)) {
|
|
336
|
+
for (const s of raw.summary) {
|
|
337
|
+
if (isRec(s) && typeof s.text === "string" && s.text.length > 0) parts.push(s.text);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(raw.content)) {
|
|
341
|
+
for (const s of raw.content) {
|
|
342
|
+
if (isRec(s) && typeof s.text === "string" && s.text.length > 0) parts.push(s.text);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (parts.length > 0) {
|
|
346
|
+
content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: `ocx${Date.now()}` });
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "function_call": {
|
|
351
|
+
sawToolUse = true;
|
|
352
|
+
let input: unknown = {};
|
|
353
|
+
if (typeof raw.arguments === "string" && raw.arguments.length > 0) {
|
|
354
|
+
try { input = JSON.parse(raw.arguments); } catch { input = {}; }
|
|
355
|
+
}
|
|
356
|
+
content.push({
|
|
357
|
+
type: "tool_use",
|
|
358
|
+
id: typeof raw.call_id === "string" ? raw.call_id : `toolu_${uuid()}`,
|
|
359
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
360
|
+
input,
|
|
361
|
+
});
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
default:
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const details = isRec(body.incomplete_details) ? body.incomplete_details : {};
|
|
370
|
+
const stopReason = body.status === "incomplete" && details.reason === "max_output_tokens"
|
|
371
|
+
? "max_tokens"
|
|
372
|
+
: sawToolUse ? "tool_use" : "end_turn";
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
id: `msg_${uuid()}`,
|
|
376
|
+
type: "message",
|
|
377
|
+
role: "assistant",
|
|
378
|
+
content,
|
|
379
|
+
model,
|
|
380
|
+
stop_reason: stopReason,
|
|
381
|
+
stop_sequence: null,
|
|
382
|
+
usage: anthropicUsage(body.usage),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Fold an Anthropic SSE stream (our own emission vocabulary) into a message JSON.
|
|
388
|
+
* Used for non-streaming client requests: the internal replay always streams
|
|
389
|
+
* (routed adapters do not support non-stream turns), so the translated stream is
|
|
390
|
+
* aggregated here instead of translating a JSON body.
|
|
391
|
+
*/
|
|
392
|
+
export async function collectAnthropicMessage(stream: ReadableStream<Uint8Array>, model: string): Promise<Rec> {
|
|
393
|
+
const decoder = new TextDecoder();
|
|
394
|
+
const reader = stream.getReader();
|
|
395
|
+
let buffer = "";
|
|
396
|
+
const content: Rec[] = [];
|
|
397
|
+
let openBlock: Rec | null = null;
|
|
398
|
+
let toolJson = "";
|
|
399
|
+
let stopReason: string | null = "end_turn";
|
|
400
|
+
let usage: Rec = anthropicUsage(undefined);
|
|
401
|
+
let error: Rec | null = null;
|
|
402
|
+
|
|
403
|
+
const closeBlock = () => {
|
|
404
|
+
if (!openBlock) return;
|
|
405
|
+
if (openBlock.type === "tool_use") {
|
|
406
|
+
try { openBlock.input = toolJson.length > 0 ? JSON.parse(toolJson) : {}; } catch { openBlock.input = {}; }
|
|
407
|
+
}
|
|
408
|
+
content.push(openBlock);
|
|
409
|
+
openBlock = null;
|
|
410
|
+
toolJson = "";
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const handle = (name: string, data: Rec) => {
|
|
414
|
+
switch (name) {
|
|
415
|
+
case "content_block_start":
|
|
416
|
+
closeBlock();
|
|
417
|
+
if (isRec(data.content_block)) openBlock = { ...data.content_block };
|
|
418
|
+
break;
|
|
419
|
+
case "content_block_delta": {
|
|
420
|
+
const delta = isRec(data.delta) ? data.delta : {};
|
|
421
|
+
if (!openBlock) break;
|
|
422
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
423
|
+
openBlock.text = `${openBlock.text ?? ""}${delta.text}`;
|
|
424
|
+
} else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
|
|
425
|
+
openBlock.thinking = `${openBlock.thinking ?? ""}${delta.thinking}`;
|
|
426
|
+
} else if (delta.type === "signature_delta" && typeof delta.signature === "string") {
|
|
427
|
+
openBlock.signature = delta.signature;
|
|
428
|
+
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
429
|
+
toolJson += delta.partial_json;
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
case "content_block_stop":
|
|
434
|
+
closeBlock();
|
|
435
|
+
break;
|
|
436
|
+
case "message_delta": {
|
|
437
|
+
const delta = isRec(data.delta) ? data.delta : {};
|
|
438
|
+
if (typeof delta.stop_reason === "string") stopReason = delta.stop_reason;
|
|
439
|
+
if (isRec(data.usage)) usage = data.usage;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "error":
|
|
443
|
+
error = data;
|
|
444
|
+
break;
|
|
445
|
+
default:
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
for (;;) {
|
|
452
|
+
const { done, value } = await reader.read();
|
|
453
|
+
if (done) break;
|
|
454
|
+
buffer += decoder.decode(value, { stream: true });
|
|
455
|
+
let sep: number;
|
|
456
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
457
|
+
const rawFrame = buffer.slice(0, sep);
|
|
458
|
+
buffer = buffer.slice(sep + 2);
|
|
459
|
+
let eventName = "";
|
|
460
|
+
let dataLine = "";
|
|
461
|
+
for (const line of rawFrame.split("\n")) {
|
|
462
|
+
if (line.startsWith("event: ")) eventName = line.slice(7).trim();
|
|
463
|
+
else if (line.startsWith("data: ")) dataLine += line.slice(6);
|
|
464
|
+
}
|
|
465
|
+
if (!eventName || !dataLine) continue;
|
|
466
|
+
let data: unknown;
|
|
467
|
+
try { data = JSON.parse(dataLine); } catch { continue; }
|
|
468
|
+
if (isRec(data)) handle(eventName, data);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
reader.releaseLock();
|
|
473
|
+
}
|
|
474
|
+
closeBlock();
|
|
475
|
+
|
|
476
|
+
if (error) return error;
|
|
477
|
+
return {
|
|
478
|
+
id: `msg_${uuid()}`,
|
|
479
|
+
type: "message",
|
|
480
|
+
role: "assistant",
|
|
481
|
+
content,
|
|
482
|
+
model,
|
|
483
|
+
stop_reason: stopReason,
|
|
484
|
+
stop_sequence: null,
|
|
485
|
+
usage,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ocx claude [claude args...]` — launch Claude Code wired to the local proxy.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1):
|
|
5
|
+
* ensures the proxy is running, injects the Anthropic env slots, then execs the
|
|
6
|
+
* `claude` CLI with stdio inherited. User-exported env always wins.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { loadConfig } from "../config";
|
|
10
|
+
import { injectClaudeAgentDefs } from "../claude/agents-inject";
|
|
11
|
+
import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
|
|
12
|
+
import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
|
|
13
|
+
import { findLiveProxy } from "../server/proxy-liveness";
|
|
14
|
+
import type { OcxConfig } from "../types";
|
|
15
|
+
|
|
16
|
+
export interface ClaudeLaunchEnv {
|
|
17
|
+
[key: string]: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
|
|
22
|
+
* token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
|
|
23
|
+
* overrides variables the user already exported.
|
|
24
|
+
*/
|
|
25
|
+
export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaunchEnv, contextWindows: Record<string, number> = {}): ClaudeLaunchEnv {
|
|
26
|
+
const env: ClaudeLaunchEnv = { ...base };
|
|
27
|
+
const setDefault = (name: string, value: string | undefined) => {
|
|
28
|
+
if (value === undefined || value.length === 0) return;
|
|
29
|
+
if (env[name] !== undefined && env[name] !== "") return; // user wins
|
|
30
|
+
env[name] = value;
|
|
31
|
+
};
|
|
32
|
+
setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
|
|
33
|
+
// Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
|
|
34
|
+
// setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
|
|
35
|
+
// the user's Claude login. Only inject a token when the proxy actually requires an
|
|
36
|
+
// admission key; otherwise Claude Code keeps its own OAuth and sends it to us —
|
|
37
|
+
// native claude models then pass through verbatim (see server/claude-messages.ts).
|
|
38
|
+
if ((config.apiKeys?.length ?? 0) > 0) {
|
|
39
|
+
setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
|
|
40
|
+
}
|
|
41
|
+
// NOTE: do NOT set _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL here. While it enables
|
|
42
|
+
// Design/Remote Control, it DISABLES gateway model discovery (Claude Code's eligibility
|
|
43
|
+
// check returns false when isFirstPartyBaseUrl() is true). Model routing through the
|
|
44
|
+
// proxy is essential; Design/Remote Control are secondary features.
|
|
45
|
+
// Connectors still work because they check OAuth state ($o()), not base URL (Gd()).
|
|
46
|
+
// Native /model picker discovery ("From gateway", Claude Code >= 2.1.129).
|
|
47
|
+
setDefault("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
|
|
48
|
+
// Opt-in effort forcing (devlog 136 B6): opus-shaped aliases already carry
|
|
49
|
+
// output_config.effort, so this is OFF unless the user enables it in config.
|
|
50
|
+
if (config.claudeCode?.alwaysEnableEffort === true) {
|
|
51
|
+
setDefault("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
|
|
52
|
+
}
|
|
53
|
+
// Context-window override: the official pair — MAX_CONTEXT_TOKENS alone is ignored
|
|
54
|
+
// for recognized claude-shaped ids unless DISABLE_COMPACT=1 rides along (devlog 135).
|
|
55
|
+
const maxCtx = config.claudeCode?.maxContextTokens;
|
|
56
|
+
if (typeof maxCtx === "number" && Number.isFinite(maxCtx) && maxCtx > 0) {
|
|
57
|
+
setDefault("CLAUDE_CODE_MAX_CONTEXT_TOKENS", String(Math.floor(maxCtx)));
|
|
58
|
+
setDefault("DISABLE_COMPACT", "1");
|
|
59
|
+
}
|
|
60
|
+
// Auto-context (devlog 260712 020): min(believed window, env) inside the CLI means
|
|
61
|
+
// one global env acts as a per-model floor — [1m]-marked models compact here while
|
|
62
|
+
// unmarked (200k-accounted) models keep their default behavior. Inert when the
|
|
63
|
+
// legacy maxContextTokens pair above is set (resolveAutoContext handles that).
|
|
64
|
+
// A user-exported value drives the marking predicate too (audit 021 #2) so the
|
|
65
|
+
// [1m] marker and the compaction threshold can never separate.
|
|
66
|
+
const userAutoCompact = typeof base.CLAUDE_CODE_AUTO_COMPACT_WINDOW === "string" && base.CLAUDE_CODE_AUTO_COMPACT_WINDOW !== ""
|
|
67
|
+
? base.CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
68
|
+
: undefined;
|
|
69
|
+
const auto = resolveAutoContext(config.claudeCode, userAutoCompact);
|
|
70
|
+
if (auto.enabled) {
|
|
71
|
+
setDefault("CLAUDE_CODE_AUTO_COMPACT_WINDOW", String(auto.compactWindow));
|
|
72
|
+
}
|
|
73
|
+
// Model slots (devlog 260712 B2): default + four tier defaults + legacy small-fast,
|
|
74
|
+
// with automatic [1m] context-variant marking when the slot's target model has an
|
|
75
|
+
// authoritative >=1M window (Claude Code then accounts 1M, compaction preserved).
|
|
76
|
+
for (const [name, value] of Object.entries(effectiveModelEnv(config.claudeCode, contextWindows, auto))) {
|
|
77
|
+
setDefault(name, value);
|
|
78
|
+
}
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Context-window map from the RUNNING proxy's management API (warm TTL cache; the
|
|
84
|
+
* daemon registers every selector form — audit R3#1). 3s bound + auth header
|
|
85
|
+
* (OPENCODEX_API_AUTH_TOKEN first, config key fallback — audit R4#1). Failure → {}
|
|
86
|
+
* (no [1m] marking, conservative).
|
|
87
|
+
*/
|
|
88
|
+
export async function fetchClaudeContextWindows(config: OcxConfig, port: number, timeoutMs = 3_000): Promise<Record<string, number>> {
|
|
89
|
+
try {
|
|
90
|
+
const headers = new Headers();
|
|
91
|
+
const token = process.env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key;
|
|
92
|
+
if (token) headers.set("x-opencodex-api-key", token);
|
|
93
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/claude-code`, {
|
|
94
|
+
headers,
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) return {};
|
|
98
|
+
const body = await res.json() as { contextWindows?: Record<string, number> };
|
|
99
|
+
return body.contextWindows && typeof body.contextWindows === "object" ? body.contextWindows : {};
|
|
100
|
+
} catch {
|
|
101
|
+
console.error("⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다.");
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ensureProxyForClaude(): Promise<number | null> {
|
|
107
|
+
const live = await findLiveProxy();
|
|
108
|
+
if (live) return live.port;
|
|
109
|
+
const child = spawn(process.execPath, [process.argv[1], "start"], {
|
|
110
|
+
detached: true,
|
|
111
|
+
stdio: "ignore",
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
env: { ...process.env, OCX_SERVICE: "1" },
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
const deadline = Date.now() + 8_000;
|
|
117
|
+
while (Date.now() < deadline) {
|
|
118
|
+
const started = await findLiveProxy();
|
|
119
|
+
if (started) return started.port;
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function cmdClaude(args: string[]): Promise<number> {
|
|
126
|
+
const config = loadConfig();
|
|
127
|
+
if (config.claudeCode?.enabled === false) {
|
|
128
|
+
console.error("Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config).");
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
const port = await ensureProxyForClaude();
|
|
132
|
+
if (!port) {
|
|
133
|
+
console.error("❌ Proxy did not become healthy after starting.");
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const contextWindows = await fetchClaudeContextWindows(config, port);
|
|
137
|
+
const env = buildClaudeEnv(config, port, process.env, contextWindows);
|
|
138
|
+
// Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
|
|
139
|
+
// never refreshes it, so the picker would keep showing yesterday's aliases.
|
|
140
|
+
await refreshGatewayModelCacheFromProxy(port);
|
|
141
|
+
// Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
|
|
142
|
+
injectClaudeAgentDefs(config, contextWindows);
|
|
143
|
+
return await new Promise<number>(resolve => {
|
|
144
|
+
const child = spawn("claude", args, { stdio: "inherit", env: env as NodeJS.ProcessEnv });
|
|
145
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
146
|
+
if (err.code === "ENOENT") {
|
|
147
|
+
console.error("❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code");
|
|
148
|
+
} else {
|
|
149
|
+
console.error(`❌ Failed to launch claude: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
resolve(1);
|
|
152
|
+
});
|
|
153
|
+
child.on("exit", (code, signal) => {
|
|
154
|
+
resolve(signal ? 1 : code ?? 0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -84,6 +84,17 @@ const helpEntries: Record<string, HelpEntry> = {
|
|
|
84
84
|
summary: "List available models from configured providers.",
|
|
85
85
|
details: ["Shows statically configured models. Providers with liveModels may have additional models at runtime."],
|
|
86
86
|
},
|
|
87
|
+
claude: {
|
|
88
|
+
usage: "ocx claude [claude args...]",
|
|
89
|
+
summary: "Launch Claude Code wired to the proxy (env injection + gateway model discovery).",
|
|
90
|
+
details: [
|
|
91
|
+
"Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,",
|
|
92
|
+
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.",
|
|
93
|
+
"Routed models appear in the native /model picker as claude-ocx-<provider>--<model> (Claude Code >= 2.1.129).",
|
|
94
|
+
"Older versions: pick models via ANTHROPIC_MODEL or /model <id> directly (any string passes through).",
|
|
95
|
+
"User-exported ANTHROPIC_* variables always take precedence.",
|
|
96
|
+
],
|
|
97
|
+
},
|
|
87
98
|
restart: {
|
|
88
99
|
usage: "ocx restart",
|
|
89
100
|
summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.",
|
|
@@ -134,6 +145,7 @@ Usage:
|
|
|
134
145
|
ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
|
|
135
146
|
ocx provider <sub> Manage providers (list|add|remove|show|set-default)
|
|
136
147
|
ocx models [--json] List available models from configured providers
|
|
148
|
+
ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
|
|
137
149
|
ocx help [command] Show help
|
|
138
150
|
ocx --version | -v Print version
|
|
139
151
|
|