glmproxy 2.5.1 → 2.6.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 +243 -270
- package/anthropic.js +734 -734
- package/bin/cli.js +406 -406
- package/lib/core.js +1454 -1434
- package/lib/prompts.js +113 -113
- package/openai.js +425 -425
- package/package.json +1 -1
package/anthropic.js
CHANGED
|
@@ -1,734 +1,734 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AutoClaw Proxy — Anthropic-format entrypoint.
|
|
3
|
-
*
|
|
4
|
-
* Owns ONLY the endpoint surface and wire format:
|
|
5
|
-
* POST /v1/messages (+ Anthropic SSE conversion state machine)
|
|
6
|
-
* GET /v1/models (Anthropic list shape), /v1/messages/count_tokens stub
|
|
7
|
-
* Claude aliases route by AutoClaw CREDIT TIER (opus→High, sonnet→Medium,
|
|
8
|
-
* haiku→Low) fetched from AutoClaw's remote model-config, degrading to
|
|
9
|
-
* heuristics when unreachable. Direct AutoClaw model IDs pass through.
|
|
10
|
-
*
|
|
11
|
-
* Claude Code CLI setup (~/.claude/settings.json):
|
|
12
|
-
* {
|
|
13
|
-
* "env": {
|
|
14
|
-
* "ANTHROPIC_BASE_URL": "http://localhost:18792",
|
|
15
|
-
* "ANTHROPIC_AUTH_TOKEN": "mewmew"
|
|
16
|
-
* }
|
|
17
|
-
* }
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
loadConfig, loadModelCatalog, getModelCatalog, createTokenLayer, createLogger,
|
|
22
|
-
createRateLimiter, createRequestLogger, createJsonlLogger,
|
|
23
|
-
makeHealthHandler, createGatewayServer, printStartupBanner, installProcessGuards,
|
|
24
|
-
sendJSON, sendErrorAnthropic, sendClassifiedErrorAnthropic, resolveClientIp,
|
|
25
|
-
readBody, validateChatPayload, generateId,
|
|
26
|
-
SSE_HEADERS, validateModelField, lastMessagePreview,
|
|
27
|
-
logUpstreamErrorBody, callUpstreamWithInvalidRequestRetry,
|
|
28
|
-
callUpstreamAnthropic, streamLocalGatewayAgent, getLocalGatewayToken,
|
|
29
|
-
classifyUpstreamError, classifyLocalAgentError, classifyTransportError,
|
|
30
|
-
shouldFallbackToLocal, createPermanentFailureCache,
|
|
31
|
-
fetchRemoteModelConfig, annotateCreditTiers, resolveTierTargets,
|
|
32
|
-
getClientHeaders, VERSION,
|
|
33
|
-
} from "./lib/core.js";
|
|
34
|
-
|
|
35
|
-
// Config (per-format log filenames come from `format`)
|
|
36
|
-
const config = loadConfig({ format: "anthropic" });
|
|
37
|
-
const { log } = createLogger(config.LOG_LEVEL);
|
|
38
|
-
const { MODELS } = loadModelCatalog(config);
|
|
39
|
-
const { getToken, invalidateToken, startWatch } = createTokenLayer(config, log);
|
|
40
|
-
const { rateLimit, startBucketSweep } = createRateLimiter(config.RATE_LIMIT);
|
|
41
|
-
const { logRequest } = createRequestLogger(config.REQUEST_LOG_FILE);
|
|
42
|
-
const { logJsonl } = createJsonlLogger({ enabled: config.JSONL_LOG, sync: config.JSONL_SYNC, file: config.JSONL_FILE, maxBytes: config.JSONL_MAX_BYTES });
|
|
43
|
-
|
|
44
|
-
// Remembers models that failed PERMANENTLY (quota exhausted, unknown id) so
|
|
45
|
-
// repeat requests fail instantly instead of replaying doomed attempts.
|
|
46
|
-
const permanentFailures = createPermanentFailureCache();
|
|
47
|
-
|
|
48
|
-
function invalidateAuth() {
|
|
49
|
-
invalidateToken();
|
|
50
|
-
permanentFailures.clear();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
startWatch();
|
|
54
|
-
startBucketSweep();
|
|
55
|
-
|
|
56
|
-
// ─── Credit-tier routing ────────────────────────────────────────────────────
|
|
57
|
-
// Heuristic tiers apply immediately (startup never blocks on the network);
|
|
58
|
-
// the remote model-config refresh lands in the background and re-computes
|
|
59
|
-
// the targets once it arrives.
|
|
60
|
-
|
|
61
|
-
let tierTargets = resolveTierTargets(annotateCreditTiers(MODELS, null));
|
|
62
|
-
|
|
63
|
-
async function refreshTiers() {
|
|
64
|
-
let jwt = null;
|
|
65
|
-
try { jwt = getToken(); } catch { return; } // no token yet — heuristics only
|
|
66
|
-
const remote = await fetchRemoteModelConfig(config, jwt);
|
|
67
|
-
if (!remote) return;
|
|
68
|
-
tierTargets = resolveTierTargets(annotateCreditTiers(getModelCatalog(config).models, remote));
|
|
69
|
-
log.info(`Credit-tier routing: opus→${tierTargets.opus} sonnet→${tierTargets.sonnet} haiku→${tierTargets.haiku} default→${tierTargets.default}`);
|
|
70
|
-
}
|
|
71
|
-
refreshTiers();
|
|
72
|
-
|
|
73
|
-
// Resolve any Anthropic model name to an AutoClaw model ID:
|
|
74
|
-
// exact catalog IDs pass through untouched; claude-* names map by class.
|
|
75
|
-
function resolveModel(anthropicModel) {
|
|
76
|
-
if (!anthropicModel) return tierTargets.default;
|
|
77
|
-
const { models } = getModelCatalog(config);
|
|
78
|
-
if (models.some((m) => m.id === anthropicModel)) return anthropicModel;
|
|
79
|
-
const CLASS_MAP = [
|
|
80
|
-
{ pattern: /opus/i, target: tierTargets.opus },
|
|
81
|
-
{ pattern: /sonnet/i, target: tierTargets.sonnet },
|
|
82
|
-
{ pattern: /haiku/i, target: tierTargets.haiku },
|
|
83
|
-
];
|
|
84
|
-
const match = CLASS_MAP.find((c) => c.pattern.test(anthropicModel));
|
|
85
|
-
return match ? match.target : tierTargets.default;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ─── Format conversion (Anthropic <-> OpenAI) ───────────────────────────────
|
|
89
|
-
|
|
90
|
-
// Convert an Anthropic Messages request body to OpenAI chat/completions format.
|
|
91
|
-
function anthropicToOpenAI(body, modelId) {
|
|
92
|
-
const messages = [];
|
|
93
|
-
|
|
94
|
-
// System prompt
|
|
95
|
-
if (body.system) {
|
|
96
|
-
const text = typeof body.system === "string"
|
|
97
|
-
? body.system
|
|
98
|
-
: body.system.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
99
|
-
if (text) messages.push({ role: "system", content: text });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Convert messages
|
|
103
|
-
for (const msg of body.messages || []) {
|
|
104
|
-
const content = msg.content;
|
|
105
|
-
|
|
106
|
-
if (typeof content === "string") {
|
|
107
|
-
messages.push({ role: msg.role, content });
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (!Array.isArray(content)) continue;
|
|
112
|
-
|
|
113
|
-
const toolResults = [];
|
|
114
|
-
const toolUses = [];
|
|
115
|
-
const textParts = [];
|
|
116
|
-
|
|
117
|
-
for (const block of content) {
|
|
118
|
-
if (block.type === "tool_result") toolResults.push(block);
|
|
119
|
-
else if (block.type === "tool_use") toolUses.push(block);
|
|
120
|
-
else if (block.type === "text") textParts.push(block.text);
|
|
121
|
-
else if (block.type === "thinking") { /* skip */ }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// tool_result blocks → "tool" role
|
|
125
|
-
for (const tr of toolResults) {
|
|
126
|
-
let resultText;
|
|
127
|
-
if (typeof tr.content === "string") {
|
|
128
|
-
resultText = tr.content;
|
|
129
|
-
} else if (Array.isArray(tr.content)) {
|
|
130
|
-
resultText = tr.content
|
|
131
|
-
.filter((b) => b.type === "text")
|
|
132
|
-
.map((b) => b.text)
|
|
133
|
-
.join("\n");
|
|
134
|
-
} else {
|
|
135
|
-
resultText = JSON.stringify(tr.content);
|
|
136
|
-
}
|
|
137
|
-
messages.push({ role: "tool", tool_call_id: tr.tool_use_id, content: resultText });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Build the main message
|
|
141
|
-
if (msg.role === "assistant" && toolUses.length > 0) {
|
|
142
|
-
const msgObj = {
|
|
143
|
-
role: "assistant",
|
|
144
|
-
tool_calls: toolUses.map((tu) => ({
|
|
145
|
-
id: tu.id,
|
|
146
|
-
type: "function",
|
|
147
|
-
function: { name: tu.name, arguments: JSON.stringify(tu.input) },
|
|
148
|
-
})),
|
|
149
|
-
};
|
|
150
|
-
if (textParts.length > 0) msgObj.content = textParts.join("\n");
|
|
151
|
-
messages.push(msgObj);
|
|
152
|
-
} else if (textParts.length > 0) {
|
|
153
|
-
messages.push({ role: msg.role, content: textParts.join("\n") });
|
|
154
|
-
} else if (toolResults.length === 0 && toolUses.length === 0) {
|
|
155
|
-
messages.push({ role: msg.role, content: "" });
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Convert Anthropic tool definitions → OpenAI format
|
|
160
|
-
const openAITools = body.tools?.map((t) => ({
|
|
161
|
-
type: "function",
|
|
162
|
-
function: {
|
|
163
|
-
name: t.name,
|
|
164
|
-
description: t.description || "",
|
|
165
|
-
parameters: t.input_schema || { type: "object", properties: {} },
|
|
166
|
-
},
|
|
167
|
-
}));
|
|
168
|
-
|
|
169
|
-
let openAIToolChoice;
|
|
170
|
-
if (body.tool_choice) {
|
|
171
|
-
if (typeof body.tool_choice === "string") {
|
|
172
|
-
if (body.tool_choice === "any") openAIToolChoice = "required";
|
|
173
|
-
else if (body.tool_choice !== "auto") openAIToolChoice = body.tool_choice;
|
|
174
|
-
} else if (body.tool_choice?.type === "tool") {
|
|
175
|
-
openAIToolChoice = { type: "function", function: { name: body.tool_choice.name } };
|
|
176
|
-
} else if (body.tool_choice?.type === "any") {
|
|
177
|
-
openAIToolChoice = "required";
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const result = {
|
|
182
|
-
model: modelId,
|
|
183
|
-
messages,
|
|
184
|
-
stream: true,
|
|
185
|
-
max_tokens: body.max_tokens ?? 4096,
|
|
186
|
-
temperature: body.temperature ?? undefined,
|
|
187
|
-
top_p: body.top_p ?? undefined,
|
|
188
|
-
stop: body.stop_sequences?.length ? body.stop_sequences : undefined,
|
|
189
|
-
};
|
|
190
|
-
if (openAITools?.length) result.tools = openAITools;
|
|
191
|
-
if (openAIToolChoice) result.tool_choice = openAIToolChoice;
|
|
192
|
-
|
|
193
|
-
return result;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Map an OpenAI finish_reason onto Anthropic stop_reason vocabulary
|
|
197
|
-
function anthropicStopReason(finishReason) {
|
|
198
|
-
return finishReason === "stop" || !finishReason ? "end_turn" : finishReason;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Buffer all OpenAI SSE chunks and assemble a single Anthropic response object.
|
|
202
|
-
function openAIChunksToAnthropic(raw, modelId, inputTokens) {
|
|
203
|
-
let content = "", reasoning = "";
|
|
204
|
-
let id = `msg_${generateId()}`;
|
|
205
|
-
let model = modelId;
|
|
206
|
-
let outputTokens = 0;
|
|
207
|
-
let stopReason = "end_turn";
|
|
208
|
-
const toolCalls = {};
|
|
209
|
-
|
|
210
|
-
for (const line of raw.split("\n")) {
|
|
211
|
-
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
|
212
|
-
try {
|
|
213
|
-
const chunk = JSON.parse(line.slice(6));
|
|
214
|
-
if (chunk.id) id = chunk.id;
|
|
215
|
-
if (chunk.model) model = chunk.model;
|
|
216
|
-
const delta = chunk.choices?.[0]?.delta;
|
|
217
|
-
if (delta?.content) content += delta.content;
|
|
218
|
-
if (delta?.reasoning_content) reasoning += delta.reasoning_content;
|
|
219
|
-
for (const tc of delta?.tool_calls || []) {
|
|
220
|
-
if (!toolCalls[tc.index]) toolCalls[tc.index] = { id: "", name: "", arguments: "" };
|
|
221
|
-
if (tc.id) toolCalls[tc.index].id = tc.id;
|
|
222
|
-
if (tc.function?.name) toolCalls[tc.index].name = tc.function.name;
|
|
223
|
-
if (tc.function?.arguments) toolCalls[tc.index].arguments += tc.function.arguments;
|
|
224
|
-
}
|
|
225
|
-
const fr = chunk.choices?.[0]?.finish_reason;
|
|
226
|
-
if (fr === "length") stopReason = "max_tokens";
|
|
227
|
-
if (fr === "tool_calls") stopReason = "tool_use";
|
|
228
|
-
if (chunk.usage) outputTokens = chunk.usage.completion_tokens ?? 0;
|
|
229
|
-
} catch { /* skip malformed lines */ }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const contentBlocks = [];
|
|
233
|
-
if (reasoning) contentBlocks.push({ type: "thinking", thinking: reasoning });
|
|
234
|
-
|
|
235
|
-
const sortedIndices = Object.keys(toolCalls).sort((a, b) => Number(a) - Number(b));
|
|
236
|
-
for (const idx of sortedIndices) {
|
|
237
|
-
const tc = toolCalls[idx];
|
|
238
|
-
let input = {};
|
|
239
|
-
try { input = JSON.parse(tc.arguments); } catch { /* partial JSON */ }
|
|
240
|
-
contentBlocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (content) contentBlocks.push({ type: "text", text: content });
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
id,
|
|
247
|
-
type: "message",
|
|
248
|
-
role: "assistant",
|
|
249
|
-
model,
|
|
250
|
-
content: contentBlocks,
|
|
251
|
-
stop_reason: stopReason,
|
|
252
|
-
stop_sequence: null,
|
|
253
|
-
usage: {
|
|
254
|
-
input_tokens: inputTokens ?? 0,
|
|
255
|
-
output_tokens: outputTokens,
|
|
256
|
-
},
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Convert a single OpenAI SSE line to one or more Anthropic SSE event strings.
|
|
261
|
-
function openAIChunkToAnthropicEvents(line, state) {
|
|
262
|
-
if (!line.startsWith("data: ")) return [];
|
|
263
|
-
|
|
264
|
-
if (line === "data: [DONE]") {
|
|
265
|
-
const events = [];
|
|
266
|
-
for (const idx of Object.keys(state.toolState).sort((a, b) => Number(a) - Number(b))) {
|
|
267
|
-
const ts = state.toolState[idx];
|
|
268
|
-
if (ts.opened && !ts.closed) {
|
|
269
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: ts.blockIdx }));
|
|
270
|
-
ts.closed = true;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
if (state.blockOpen) {
|
|
274
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
275
|
-
state.blockOpen = false;
|
|
276
|
-
}
|
|
277
|
-
events.push(fmt("message_delta", {
|
|
278
|
-
type: "message_delta",
|
|
279
|
-
delta: { stop_reason: state.finishReason || "end_turn", stop_sequence: null },
|
|
280
|
-
usage: { output_tokens: state.outputTokens },
|
|
281
|
-
}));
|
|
282
|
-
events.push(fmt("message_stop", { type: "message_stop" }));
|
|
283
|
-
return events;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let chunk;
|
|
287
|
-
try { chunk = JSON.parse(line.slice(6)); } catch { return []; }
|
|
288
|
-
|
|
289
|
-
const delta = chunk.choices?.[0]?.delta;
|
|
290
|
-
if (!delta) return [];
|
|
291
|
-
|
|
292
|
-
const events = [];
|
|
293
|
-
const content = delta.content ?? "";
|
|
294
|
-
const reasoning = delta.reasoning_content ?? "";
|
|
295
|
-
const toolCalls = delta.tool_calls || [];
|
|
296
|
-
const fr = chunk.choices?.[0]?.finish_reason;
|
|
297
|
-
|
|
298
|
-
if (chunk.usage) state.outputTokens = chunk.usage.completion_tokens ?? state.outputTokens;
|
|
299
|
-
if (fr === "length") state.finishReason = "max_tokens";
|
|
300
|
-
if (fr === "stop") state.finishReason = "end_turn";
|
|
301
|
-
if (fr === "tool_calls") state.finishReason = "tool_use";
|
|
302
|
-
|
|
303
|
-
// Tool calls
|
|
304
|
-
for (const tc of toolCalls) {
|
|
305
|
-
const idx = tc.index;
|
|
306
|
-
if (!state.toolState[idx]) {
|
|
307
|
-
state.toolState[idx] = { id: "", name: "", arguments: "", opened: false, closed: false, blockIdx: -1 };
|
|
308
|
-
}
|
|
309
|
-
const ts = state.toolState[idx];
|
|
310
|
-
if (tc.id) ts.id = tc.id;
|
|
311
|
-
if (tc.function?.name) ts.name = tc.function.name;
|
|
312
|
-
if (tc.function?.arguments) ts.arguments += tc.function.arguments;
|
|
313
|
-
|
|
314
|
-
if (ts.name && !ts.opened) {
|
|
315
|
-
if (state.blockOpen) {
|
|
316
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
317
|
-
state.blockIndex++;
|
|
318
|
-
state.blockOpen = false;
|
|
319
|
-
state.thinkingOpen = false;
|
|
320
|
-
state.textOpen = false;
|
|
321
|
-
}
|
|
322
|
-
ts.blockIdx = state.blockIndex;
|
|
323
|
-
events.push(fmt("content_block_start", {
|
|
324
|
-
type: "content_block_start", index: state.blockIndex,
|
|
325
|
-
content_block: { type: "tool_use", id: ts.id, name: ts.name, input: {} },
|
|
326
|
-
}));
|
|
327
|
-
ts.opened = true;
|
|
328
|
-
state.blockOpen = true;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (ts.opened && tc.function?.arguments) {
|
|
332
|
-
events.push(fmt("content_block_delta", {
|
|
333
|
-
type: "content_block_delta", index: ts.blockIdx,
|
|
334
|
-
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
|
|
335
|
-
}));
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// Reasoning
|
|
340
|
-
if (reasoning && !state.thinkingOpen) {
|
|
341
|
-
if (state.blockOpen) {
|
|
342
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
343
|
-
state.blockIndex++;
|
|
344
|
-
state.blockOpen = false;
|
|
345
|
-
state.textOpen = false;
|
|
346
|
-
}
|
|
347
|
-
events.push(fmt("content_block_start", {
|
|
348
|
-
type: "content_block_start", index: state.blockIndex,
|
|
349
|
-
content_block: { type: "thinking", thinking: "" },
|
|
350
|
-
}));
|
|
351
|
-
state.thinkingOpen = true;
|
|
352
|
-
state.blockOpen = true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (reasoning) {
|
|
356
|
-
events.push(fmt("content_block_delta", {
|
|
357
|
-
type: "content_block_delta", index: state.blockIndex,
|
|
358
|
-
delta: { type: "thinking_delta", thinking: reasoning },
|
|
359
|
-
}));
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Text
|
|
363
|
-
if (content && !state.textOpen) {
|
|
364
|
-
if (state.thinkingOpen) {
|
|
365
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
366
|
-
state.blockIndex++;
|
|
367
|
-
state.thinkingOpen = false;
|
|
368
|
-
state.blockOpen = false;
|
|
369
|
-
}
|
|
370
|
-
for (const idx of Object.keys(state.toolState).sort((a, b) => Number(a) - Number(b))) {
|
|
371
|
-
const ts = state.toolState[idx];
|
|
372
|
-
if (ts.opened && !ts.closed) {
|
|
373
|
-
events.push(fmt("content_block_stop", { type: "content_block_stop", index: ts.blockIdx }));
|
|
374
|
-
state.blockIndex++;
|
|
375
|
-
ts.closed = true;
|
|
376
|
-
state.blockOpen = false;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (!state.blockOpen) {
|
|
380
|
-
events.push(fmt("content_block_start", {
|
|
381
|
-
type: "content_block_start", index: state.blockIndex,
|
|
382
|
-
content_block: { type: "text", text: "" },
|
|
383
|
-
}));
|
|
384
|
-
state.textOpen = true;
|
|
385
|
-
state.blockOpen = true;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (content) {
|
|
390
|
-
events.push(fmt("content_block_delta", {
|
|
391
|
-
type: "content_block_delta", index: state.blockIndex,
|
|
392
|
-
delta: { type: "text_delta", text: content },
|
|
393
|
-
}));
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return events;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function fmt(event, data) {
|
|
400
|
-
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// ─── Routes ─────────────────────────────────────────────────────────────────
|
|
404
|
-
|
|
405
|
-
function handleModels(req, res) {
|
|
406
|
-
const { models } = getModelCatalog(config);
|
|
407
|
-
const data = models.map((m) => ({
|
|
408
|
-
type: "model",
|
|
409
|
-
id: m.id,
|
|
410
|
-
display_name: m.name,
|
|
411
|
-
created_at: new Date().toISOString(),
|
|
412
|
-
}));
|
|
413
|
-
sendJSON(res, {
|
|
414
|
-
data,
|
|
415
|
-
has_more: false,
|
|
416
|
-
first_id: data[0]?.id ?? null,
|
|
417
|
-
last_id: data[data.length - 1]?.id ?? null,
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
async function handleMessages(req, res) {
|
|
422
|
-
const startTime = Date.now();
|
|
423
|
-
const clientIp = resolveClientIp(req);
|
|
424
|
-
|
|
425
|
-
// Model identity isn't known until after conversion — keep these above
|
|
426
|
-
// record() so validation failures can still log safely (null = unknown).
|
|
427
|
-
let currentModelId = null;
|
|
428
|
-
let currentAnthropicModel = null;
|
|
429
|
-
|
|
430
|
-
// Exactly one observability entry per request (`via` marks cloud vs local).
|
|
431
|
-
let recorded = false;
|
|
432
|
-
function record(status, { lastMessage = null, messageCount = 0, error, via = "cloud", cloud_status, cloud_error } = {}) {
|
|
433
|
-
if (recorded) return;
|
|
434
|
-
recorded = true;
|
|
435
|
-
logRequest({
|
|
436
|
-
timestamp: new Date().toISOString(),
|
|
437
|
-
model: currentModelId, anthropic_model: currentAnthropicModel, status, via,
|
|
438
|
-
last_message: typeof lastMessage === "string"
|
|
439
|
-
? lastMessage.substring(0, 300)
|
|
440
|
-
: JSON.stringify(lastMessage)?.substring(0, 300) ?? "",
|
|
441
|
-
...(messageCount ? { message_count: messageCount } : {}),
|
|
442
|
-
...(error ? { error } : {}),
|
|
443
|
-
...(cloud_status ? { cloud_status, ...(cloud_error ? { cloud_error } : {}) } : {}),
|
|
444
|
-
});
|
|
445
|
-
logJsonl({ model: currentModelId, status, ip: clientIp, latencyMs: Date.now() - startTime, ...(via !== "cloud" ? { via } : {}), ...(error ? { error } : {}) });
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// (logUpstreamErrorBody lives in lib/core.js — shared with openai.js)
|
|
449
|
-
|
|
450
|
-
let body;
|
|
451
|
-
try {
|
|
452
|
-
body = await readBody(req, config.MAX_BODY_BYTES);
|
|
453
|
-
} catch (err) {
|
|
454
|
-
record(err.statusCode || 400, { error: "invalid_request" });
|
|
455
|
-
return sendErrorAnthropic(res, err.message, "invalid_request_error", err.statusCode || 400, "invalid_request");
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const modelFieldError = validateModelField(body);
|
|
459
|
-
if (modelFieldError) {
|
|
460
|
-
record(400, { error: "invalid_request" });
|
|
461
|
-
return sendErrorAnthropic(res, modelFieldError.message, modelFieldError.type, modelFieldError.status, modelFieldError.code);
|
|
462
|
-
}
|
|
463
|
-
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
464
|
-
record(400, { error: "invalid_request" });
|
|
465
|
-
return sendErrorAnthropic(res, "messages must be a non-empty array", "invalid_request_error", 400, "invalid_messages");
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const modelId = resolveModel(body.model);
|
|
469
|
-
const stream = body.stream === true; // Anthropic defaults to non-streaming
|
|
470
|
-
const openAIBody = anthropicToOpenAI(body, modelId);
|
|
471
|
-
const payloadError = validateChatPayload(openAIBody, config.MAX_MESSAGES);
|
|
472
|
-
if (payloadError) {
|
|
473
|
-
record(payloadError.statusCode, { error: "payload_too_large" });
|
|
474
|
-
return sendErrorAnthropic(res, payloadError.message, "invalid_request_error", payloadError.statusCode, "invalid_payload");
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
currentModelId = modelId;
|
|
478
|
-
currentAnthropicModel = body.model;
|
|
479
|
-
log.info(`messages model=${body.model} -> ${modelId} stream=${stream}`);
|
|
480
|
-
|
|
481
|
-
const lastMsgForLog = () => lastMessagePreview(openAIBody.messages);
|
|
482
|
-
|
|
483
|
-
// Local AutoClaw WebSocket agent fallback (same trigger rules as the OpenAI
|
|
484
|
-
// entrypoint — this is what gives Anthropic its 402/403/5xx parity).
|
|
485
|
-
// Set when the cloud upstream rejects the request before fallback runs;
|
|
486
|
-
// consumed by record() so the terminal entry carries the cloud verdict.
|
|
487
|
-
let cloudEvidence = null;
|
|
488
|
-
const tryLocalAgent = () => {
|
|
489
|
-
if (!getLocalGatewayToken()) return Promise.resolve(false);
|
|
490
|
-
log.info(`Executing chat model=${modelId} via local AutoClaw WebSocket agent...`);
|
|
491
|
-
return new Promise((resolve) => {
|
|
492
|
-
let fullContent = "";
|
|
493
|
-
let streamedStart = false;
|
|
494
|
-
const startedAt = Date.now();
|
|
495
|
-
|
|
496
|
-
streamLocalGatewayAgent({
|
|
497
|
-
config,
|
|
498
|
-
modelId,
|
|
499
|
-
messages: openAIBody.messages,
|
|
500
|
-
onChunk: ({ delta }) => {
|
|
501
|
-
if (stream) {
|
|
502
|
-
if (!streamedStart) {
|
|
503
|
-
streamedStart = true;
|
|
504
|
-
res.writeHead(200, SSE_HEADERS);
|
|
505
|
-
res.write(fmt("message_start", {
|
|
506
|
-
type: "message_start",
|
|
507
|
-
message: {
|
|
508
|
-
id: `msg_${generateId()}`, type: "message", role: "assistant",
|
|
509
|
-
model: body.model, content: [], stop_reason: null, stop_sequence: null,
|
|
510
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
511
|
-
},
|
|
512
|
-
}));
|
|
513
|
-
res.write(fmt("content_block_start", {
|
|
514
|
-
type: "content_block_start", index: 0,
|
|
515
|
-
content_block: { type: "text", text: "" },
|
|
516
|
-
}));
|
|
517
|
-
}
|
|
518
|
-
res.write(fmt("content_block_delta", {
|
|
519
|
-
type: "content_block_delta", index: 0,
|
|
520
|
-
delta: { type: "text_delta", text: delta },
|
|
521
|
-
}));
|
|
522
|
-
} else {
|
|
523
|
-
fullContent += delta;
|
|
524
|
-
}
|
|
525
|
-
},
|
|
526
|
-
onEnd: ({ finishReason }) => {
|
|
527
|
-
if (stream) {
|
|
528
|
-
res.write(fmt("content_block_stop", { type: "content_block_stop", index: 0 }));
|
|
529
|
-
res.write(fmt("message_delta", {
|
|
530
|
-
type: "message_delta",
|
|
531
|
-
delta: { stop_reason: anthropicStopReason(finishReason), stop_sequence: null },
|
|
532
|
-
usage: { output_tokens: 0 },
|
|
533
|
-
}));
|
|
534
|
-
res.write(fmt("message_stop", { type: "message_stop" }));
|
|
535
|
-
res.end();
|
|
536
|
-
} else {
|
|
537
|
-
sendJSON(res, {
|
|
538
|
-
id: `msg_${generateId()}`,
|
|
539
|
-
type: "message",
|
|
540
|
-
role: "assistant",
|
|
541
|
-
model: body.model,
|
|
542
|
-
content: [{ type: "text", text: fullContent }],
|
|
543
|
-
stop_reason: anthropicStopReason(finishReason),
|
|
544
|
-
stop_sequence: null,
|
|
545
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
546
|
-
});
|
|
547
|
-
}
|
|
548
|
-
log.info(`chat model=${modelId} served via local agent (${Date.now() - startedAt}ms)`);
|
|
549
|
-
record(200, {
|
|
550
|
-
lastMessage: fullContent,
|
|
551
|
-
messageCount: openAIBody.messages?.length || 0,
|
|
552
|
-
via: "local",
|
|
553
|
-
...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
|
|
554
|
-
});
|
|
555
|
-
resolve(true);
|
|
556
|
-
},
|
|
557
|
-
onError: (err) => {
|
|
558
|
-
log.warn(`Local gateway execution failed: ${err.message}`);
|
|
559
|
-
const cls = classifyLocalAgentError(err, modelId);
|
|
560
|
-
permanentFailures.mark(modelId, cls);
|
|
561
|
-
if (res.headersSent) {
|
|
562
|
-
// Stream already started — close it rather than throwing a
|
|
563
|
-
// second writeHead onto a spent response.
|
|
564
|
-
try { res.end(); } catch (_) {}
|
|
565
|
-
record(cls.status, { error: `${cls.code} (mid-stream)`, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
|
|
566
|
-
} else {
|
|
567
|
-
record(cls.status, { error: cls.code, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
|
|
568
|
-
sendClassifiedErrorAnthropic(res, cls);
|
|
569
|
-
}
|
|
570
|
-
resolve(true);
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
});
|
|
574
|
-
};
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
// PREFER_LOCAL=1 fast path — skip doomed cloud attempts entirely.
|
|
578
|
-
if (config.PREFER_LOCAL && getLocalGatewayToken()) {
|
|
579
|
-
if (await tryLocalAgent()) return;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const cachedFailure = permanentFailures.get(modelId);
|
|
583
|
-
if (cachedFailure) {
|
|
584
|
-
log.info(`chat model=${modelId} short-circuited: ${cachedFailure.code} (recently confirmed)`);
|
|
585
|
-
record(cachedFailure.status, { error: cachedFailure.code });
|
|
586
|
-
return sendClassifiedErrorAnthropic(res, cachedFailure);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Cloud call with one retry on the flaky 400 "invalid request" hiccup;
|
|
590
|
-
// every >=400 body is buffered + logged (R1). Shared with openai.js.
|
|
591
|
-
const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
|
|
592
|
-
() => callUpstreamAnthropic(config, getClientHeaders(config), getToken, openAIBody, modelId),
|
|
593
|
-
modelId, permanentFailures, log,
|
|
594
|
-
);
|
|
595
|
-
|
|
596
|
-
const statusCode = upstreamRes.statusCode;
|
|
597
|
-
|
|
598
|
-
// Rotate token caches BEFORE deciding fallback so the very next request
|
|
599
|
-
// picks up the fresh JWT regardless of who serves this one.
|
|
600
|
-
if (statusCode === 401) invalidateAuth();
|
|
601
|
-
|
|
602
|
-
if (shouldFallbackToLocal(statusCode)) {
|
|
603
|
-
const cls = classifyUpstreamError(statusCode, upstreamErrBody, modelId);
|
|
604
|
-
if (cls.permanent) permanentFailures.mark(modelId, cls);
|
|
605
|
-
log.error(`Upstream error ${statusCode}:`, cls.message);
|
|
606
|
-
cloudEvidence = { status: statusCode, code: cls.code };
|
|
607
|
-
|
|
608
|
-
// The desktop gateway shares this AutoClaw account — quota walls stop
|
|
609
|
-
// it too, so don't march known-permanent failures into it.
|
|
610
|
-
if (!cls.permanent || !permanentFailures.get(modelId)) {
|
|
611
|
-
if (await tryLocalAgent()) return;
|
|
612
|
-
} else {
|
|
613
|
-
log.info(`Skipping local fallback for ${modelId}: ${cls.code} is account-wide`);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
record(cls.status, {
|
|
617
|
-
lastMessage: lastMsgForLog(),
|
|
618
|
-
messageCount: openAIBody.messages?.length || 0,
|
|
619
|
-
error: cls.code,
|
|
620
|
-
...(statusCode !== cls.status ? { cloud_status: statusCode, cloud_error: cls.code } : {}),
|
|
621
|
-
});
|
|
622
|
-
return sendClassifiedErrorAnthropic(res, cls);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Success paths
|
|
626
|
-
record(statusCode, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0 });
|
|
627
|
-
|
|
628
|
-
if (stream) {
|
|
629
|
-
res.writeHead(200, SSE_HEADERS);
|
|
630
|
-
|
|
631
|
-
res.write(fmt("message_start", {
|
|
632
|
-
type: "message_start",
|
|
633
|
-
message: {
|
|
634
|
-
id: `msg_${generateId()}`, type: "message", role: "assistant",
|
|
635
|
-
model: modelId, content: [], stop_reason: null, stop_sequence: null,
|
|
636
|
-
usage: { input_tokens: 0, output_tokens: 0 },
|
|
637
|
-
},
|
|
638
|
-
}));
|
|
639
|
-
res.write(fmt("ping", { type: "ping" }));
|
|
640
|
-
|
|
641
|
-
const state = {
|
|
642
|
-
blockIndex: 0, blockOpen: false,
|
|
643
|
-
thinkingOpen: false, textOpen: false,
|
|
644
|
-
outputTokens: 0, finishReason: "end_turn",
|
|
645
|
-
toolState: {},
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
let buffer = "";
|
|
649
|
-
upstreamRes.on("data", (chunk) => {
|
|
650
|
-
buffer += chunk.toString();
|
|
651
|
-
const lines = buffer.split("\n");
|
|
652
|
-
buffer = lines.pop();
|
|
653
|
-
for (const line of lines) {
|
|
654
|
-
for (const e of openAIChunkToAnthropicEvents(line.trim(), state)) res.write(e);
|
|
655
|
-
}
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
upstreamRes.on("end", () => {
|
|
659
|
-
if (buffer.trim()) {
|
|
660
|
-
for (const e of openAIChunkToAnthropicEvents(buffer.trim(), state)) res.write(e);
|
|
661
|
-
}
|
|
662
|
-
for (const e of openAIChunkToAnthropicEvents("data: [DONE]", state)) res.write(e);
|
|
663
|
-
res.end();
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
upstreamRes.on("error", (err) => { log.error("Stream error:", err); res.end(); });
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Non-stream: buffer everything into one Anthropic response object.
|
|
671
|
-
let raw = "";
|
|
672
|
-
upstreamRes.on("data", (c) => (raw += c));
|
|
673
|
-
upstreamRes.on("end", () => {
|
|
674
|
-
try {
|
|
675
|
-
const inputTokens = (body.messages?.length ?? 1) * 10; // rough estimate only
|
|
676
|
-
sendJSON(res, openAIChunksToAnthropic(raw, modelId, inputTokens));
|
|
677
|
-
} catch (err) {
|
|
678
|
-
if (!res.headersSent) sendErrorAnthropic(res, `Failed to parse upstream response: ${err.message}`, "api_error", 502, "upstream_parse_failed");
|
|
679
|
-
else { try { res.end(); } catch (_) {} }
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
|
-
} catch (err) {
|
|
683
|
-
const cls = classifyTransportError(err);
|
|
684
|
-
log.error(`messages model=${body.model} transport failure:`, cls.message);
|
|
685
|
-
if (!res.headersSent && shouldFallbackToLocal(cls.status)) {
|
|
686
|
-
if (await tryLocalAgent()) return;
|
|
687
|
-
}
|
|
688
|
-
if (res.headersSent) { try { res.end(); } catch (_) {} return; }
|
|
689
|
-
record(cls.status, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0, error: cls.code });
|
|
690
|
-
return sendClassifiedErrorAnthropic(res, cls);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// Server
|
|
695
|
-
|
|
696
|
-
const server = createGatewayServer({
|
|
697
|
-
config, log, rateLimit,
|
|
698
|
-
sendError: sendErrorAnthropic,
|
|
699
|
-
routes: [
|
|
700
|
-
{ method: "GET", path: "/healthz", handler: makeHealthHandler(config, getToken) },
|
|
701
|
-
{ method: "GET", path: "/v1/models", handler: handleModels },
|
|
702
|
-
{ method: "POST", path: "/v1/messages", handler: handleMessages },
|
|
703
|
-
// Claude Code probes token counts pre-flight; we don't tokenize locally,
|
|
704
|
-
// so report zero rather than 404-ing the whole session handshake.
|
|
705
|
-
{ path: "/v1/messages/count_tokens", handler: (req, res) => sendJSON(res, { input_tokens: 0 }) },
|
|
706
|
-
],
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
installProcessGuards(log);
|
|
710
|
-
|
|
711
|
-
server.listen(config.PORT, config.HOST, () => {
|
|
712
|
-
printStartupBanner({
|
|
713
|
-
title: `🛸 AUTOCLAW GATEWAY PROXY (Anthropic Format v${VERSION})`,
|
|
714
|
-
rows: [
|
|
715
|
-
`Host : ${config.HOST}`,
|
|
716
|
-
`Port : ${config.PORT}`,
|
|
717
|
-
`Auth Key : ${config.PROXY_KEY}`,
|
|
718
|
-
`Rate Lim : ${config.RATE_LIMIT} req/s per IP`,
|
|
719
|
-
`Max Msgs : ${Number.isFinite(config.MAX_MESSAGES) ? `${config.MAX_MESSAGES} entries` : "unlimited"}`,
|
|
720
|
-
`Models : ${MODELS.map(m => m.id).join(", ")}`,
|
|
721
|
-
"",
|
|
722
|
-
"Claude Code CLI Base URL:",
|
|
723
|
-
`http://${config.HOST}:${config.PORT}`,
|
|
724
|
-
`Routing : opus→${tierTargets.opus} sonnet→${tierTargets.sonnet} haiku→${tierTargets.haiku}`,
|
|
725
|
-
],
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
try {
|
|
729
|
-
getToken();
|
|
730
|
-
console.log(" ✅ Token loaded - ready\n");
|
|
731
|
-
} catch (e) {
|
|
732
|
-
console.warn(` ⚠️ ${e.message}\n`);
|
|
733
|
-
}
|
|
734
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* AutoClaw Proxy — Anthropic-format entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Owns ONLY the endpoint surface and wire format:
|
|
5
|
+
* POST /v1/messages (+ Anthropic SSE conversion state machine)
|
|
6
|
+
* GET /v1/models (Anthropic list shape), /v1/messages/count_tokens stub
|
|
7
|
+
* Claude aliases route by AutoClaw CREDIT TIER (opus→High, sonnet→Medium,
|
|
8
|
+
* haiku→Low) fetched from AutoClaw's remote model-config, degrading to
|
|
9
|
+
* heuristics when unreachable. Direct AutoClaw model IDs pass through.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code CLI setup (~/.claude/settings.json):
|
|
12
|
+
* {
|
|
13
|
+
* "env": {
|
|
14
|
+
* "ANTHROPIC_BASE_URL": "http://localhost:18792",
|
|
15
|
+
* "ANTHROPIC_AUTH_TOKEN": "mewmew"
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
loadConfig, loadModelCatalog, getModelCatalog, createTokenLayer, createLogger,
|
|
22
|
+
createRateLimiter, createRequestLogger, createJsonlLogger,
|
|
23
|
+
makeHealthHandler, createGatewayServer, printStartupBanner, installProcessGuards,
|
|
24
|
+
sendJSON, sendErrorAnthropic, sendClassifiedErrorAnthropic, resolveClientIp,
|
|
25
|
+
readBody, validateChatPayload, generateId,
|
|
26
|
+
SSE_HEADERS, validateModelField, lastMessagePreview,
|
|
27
|
+
logUpstreamErrorBody, callUpstreamWithInvalidRequestRetry,
|
|
28
|
+
callUpstreamAnthropic, streamLocalGatewayAgent, getLocalGatewayToken,
|
|
29
|
+
classifyUpstreamError, classifyLocalAgentError, classifyTransportError,
|
|
30
|
+
shouldFallbackToLocal, createPermanentFailureCache,
|
|
31
|
+
fetchRemoteModelConfig, annotateCreditTiers, resolveTierTargets,
|
|
32
|
+
getClientHeaders, VERSION,
|
|
33
|
+
} from "./lib/core.js";
|
|
34
|
+
|
|
35
|
+
// Config (per-format log filenames come from `format`)
|
|
36
|
+
const config = loadConfig({ format: "anthropic" });
|
|
37
|
+
const { log } = createLogger(config.LOG_LEVEL);
|
|
38
|
+
const { MODELS } = loadModelCatalog(config);
|
|
39
|
+
const { getToken, invalidateToken, startWatch } = createTokenLayer(config, log);
|
|
40
|
+
const { rateLimit, startBucketSweep } = createRateLimiter(config.RATE_LIMIT);
|
|
41
|
+
const { logRequest } = createRequestLogger(config.REQUEST_LOG_FILE);
|
|
42
|
+
const { logJsonl } = createJsonlLogger({ enabled: config.JSONL_LOG, sync: config.JSONL_SYNC, file: config.JSONL_FILE, maxBytes: config.JSONL_MAX_BYTES });
|
|
43
|
+
|
|
44
|
+
// Remembers models that failed PERMANENTLY (quota exhausted, unknown id) so
|
|
45
|
+
// repeat requests fail instantly instead of replaying doomed attempts.
|
|
46
|
+
const permanentFailures = createPermanentFailureCache();
|
|
47
|
+
|
|
48
|
+
function invalidateAuth() {
|
|
49
|
+
invalidateToken();
|
|
50
|
+
permanentFailures.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
startWatch();
|
|
54
|
+
startBucketSweep();
|
|
55
|
+
|
|
56
|
+
// ─── Credit-tier routing ────────────────────────────────────────────────────
|
|
57
|
+
// Heuristic tiers apply immediately (startup never blocks on the network);
|
|
58
|
+
// the remote model-config refresh lands in the background and re-computes
|
|
59
|
+
// the targets once it arrives.
|
|
60
|
+
|
|
61
|
+
let tierTargets = resolveTierTargets(annotateCreditTiers(MODELS, null));
|
|
62
|
+
|
|
63
|
+
async function refreshTiers() {
|
|
64
|
+
let jwt = null;
|
|
65
|
+
try { jwt = getToken(); } catch { return; } // no token yet — heuristics only
|
|
66
|
+
const remote = await fetchRemoteModelConfig(config, jwt);
|
|
67
|
+
if (!remote) return;
|
|
68
|
+
tierTargets = resolveTierTargets(annotateCreditTiers(getModelCatalog(config).models, remote));
|
|
69
|
+
log.info(`Credit-tier routing: opus→${tierTargets.opus} sonnet→${tierTargets.sonnet} haiku→${tierTargets.haiku} default→${tierTargets.default}`);
|
|
70
|
+
}
|
|
71
|
+
refreshTiers();
|
|
72
|
+
|
|
73
|
+
// Resolve any Anthropic model name to an AutoClaw model ID:
|
|
74
|
+
// exact catalog IDs pass through untouched; claude-* names map by class.
|
|
75
|
+
function resolveModel(anthropicModel) {
|
|
76
|
+
if (!anthropicModel) return tierTargets.default;
|
|
77
|
+
const { models } = getModelCatalog(config);
|
|
78
|
+
if (models.some((m) => m.id === anthropicModel)) return anthropicModel;
|
|
79
|
+
const CLASS_MAP = [
|
|
80
|
+
{ pattern: /opus/i, target: tierTargets.opus },
|
|
81
|
+
{ pattern: /sonnet/i, target: tierTargets.sonnet },
|
|
82
|
+
{ pattern: /haiku/i, target: tierTargets.haiku },
|
|
83
|
+
];
|
|
84
|
+
const match = CLASS_MAP.find((c) => c.pattern.test(anthropicModel));
|
|
85
|
+
return match ? match.target : tierTargets.default;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Format conversion (Anthropic <-> OpenAI) ───────────────────────────────
|
|
89
|
+
|
|
90
|
+
// Convert an Anthropic Messages request body to OpenAI chat/completions format.
|
|
91
|
+
function anthropicToOpenAI(body, modelId) {
|
|
92
|
+
const messages = [];
|
|
93
|
+
|
|
94
|
+
// System prompt
|
|
95
|
+
if (body.system) {
|
|
96
|
+
const text = typeof body.system === "string"
|
|
97
|
+
? body.system
|
|
98
|
+
: body.system.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
99
|
+
if (text) messages.push({ role: "system", content: text });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Convert messages
|
|
103
|
+
for (const msg of body.messages || []) {
|
|
104
|
+
const content = msg.content;
|
|
105
|
+
|
|
106
|
+
if (typeof content === "string") {
|
|
107
|
+
messages.push({ role: msg.role, content });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!Array.isArray(content)) continue;
|
|
112
|
+
|
|
113
|
+
const toolResults = [];
|
|
114
|
+
const toolUses = [];
|
|
115
|
+
const textParts = [];
|
|
116
|
+
|
|
117
|
+
for (const block of content) {
|
|
118
|
+
if (block.type === "tool_result") toolResults.push(block);
|
|
119
|
+
else if (block.type === "tool_use") toolUses.push(block);
|
|
120
|
+
else if (block.type === "text") textParts.push(block.text);
|
|
121
|
+
else if (block.type === "thinking") { /* skip */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// tool_result blocks → "tool" role
|
|
125
|
+
for (const tr of toolResults) {
|
|
126
|
+
let resultText;
|
|
127
|
+
if (typeof tr.content === "string") {
|
|
128
|
+
resultText = tr.content;
|
|
129
|
+
} else if (Array.isArray(tr.content)) {
|
|
130
|
+
resultText = tr.content
|
|
131
|
+
.filter((b) => b.type === "text")
|
|
132
|
+
.map((b) => b.text)
|
|
133
|
+
.join("\n");
|
|
134
|
+
} else {
|
|
135
|
+
resultText = JSON.stringify(tr.content);
|
|
136
|
+
}
|
|
137
|
+
messages.push({ role: "tool", tool_call_id: tr.tool_use_id, content: resultText });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Build the main message
|
|
141
|
+
if (msg.role === "assistant" && toolUses.length > 0) {
|
|
142
|
+
const msgObj = {
|
|
143
|
+
role: "assistant",
|
|
144
|
+
tool_calls: toolUses.map((tu) => ({
|
|
145
|
+
id: tu.id,
|
|
146
|
+
type: "function",
|
|
147
|
+
function: { name: tu.name, arguments: JSON.stringify(tu.input) },
|
|
148
|
+
})),
|
|
149
|
+
};
|
|
150
|
+
if (textParts.length > 0) msgObj.content = textParts.join("\n");
|
|
151
|
+
messages.push(msgObj);
|
|
152
|
+
} else if (textParts.length > 0) {
|
|
153
|
+
messages.push({ role: msg.role, content: textParts.join("\n") });
|
|
154
|
+
} else if (toolResults.length === 0 && toolUses.length === 0) {
|
|
155
|
+
messages.push({ role: msg.role, content: "" });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Convert Anthropic tool definitions → OpenAI format
|
|
160
|
+
const openAITools = body.tools?.map((t) => ({
|
|
161
|
+
type: "function",
|
|
162
|
+
function: {
|
|
163
|
+
name: t.name,
|
|
164
|
+
description: t.description || "",
|
|
165
|
+
parameters: t.input_schema || { type: "object", properties: {} },
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
let openAIToolChoice;
|
|
170
|
+
if (body.tool_choice) {
|
|
171
|
+
if (typeof body.tool_choice === "string") {
|
|
172
|
+
if (body.tool_choice === "any") openAIToolChoice = "required";
|
|
173
|
+
else if (body.tool_choice !== "auto") openAIToolChoice = body.tool_choice;
|
|
174
|
+
} else if (body.tool_choice?.type === "tool") {
|
|
175
|
+
openAIToolChoice = { type: "function", function: { name: body.tool_choice.name } };
|
|
176
|
+
} else if (body.tool_choice?.type === "any") {
|
|
177
|
+
openAIToolChoice = "required";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = {
|
|
182
|
+
model: modelId,
|
|
183
|
+
messages,
|
|
184
|
+
stream: true,
|
|
185
|
+
max_tokens: body.max_tokens ?? 4096,
|
|
186
|
+
temperature: body.temperature ?? undefined,
|
|
187
|
+
top_p: body.top_p ?? undefined,
|
|
188
|
+
stop: body.stop_sequences?.length ? body.stop_sequences : undefined,
|
|
189
|
+
};
|
|
190
|
+
if (openAITools?.length) result.tools = openAITools;
|
|
191
|
+
if (openAIToolChoice) result.tool_choice = openAIToolChoice;
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Map an OpenAI finish_reason onto Anthropic stop_reason vocabulary
|
|
197
|
+
function anthropicStopReason(finishReason) {
|
|
198
|
+
return finishReason === "stop" || !finishReason ? "end_turn" : finishReason;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Buffer all OpenAI SSE chunks and assemble a single Anthropic response object.
|
|
202
|
+
function openAIChunksToAnthropic(raw, modelId, inputTokens) {
|
|
203
|
+
let content = "", reasoning = "";
|
|
204
|
+
let id = `msg_${generateId()}`;
|
|
205
|
+
let model = modelId;
|
|
206
|
+
let outputTokens = 0;
|
|
207
|
+
let stopReason = "end_turn";
|
|
208
|
+
const toolCalls = {};
|
|
209
|
+
|
|
210
|
+
for (const line of raw.split("\n")) {
|
|
211
|
+
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
|
212
|
+
try {
|
|
213
|
+
const chunk = JSON.parse(line.slice(6));
|
|
214
|
+
if (chunk.id) id = chunk.id;
|
|
215
|
+
if (chunk.model) model = chunk.model;
|
|
216
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
217
|
+
if (delta?.content) content += delta.content;
|
|
218
|
+
if (delta?.reasoning_content) reasoning += delta.reasoning_content;
|
|
219
|
+
for (const tc of delta?.tool_calls || []) {
|
|
220
|
+
if (!toolCalls[tc.index]) toolCalls[tc.index] = { id: "", name: "", arguments: "" };
|
|
221
|
+
if (tc.id) toolCalls[tc.index].id = tc.id;
|
|
222
|
+
if (tc.function?.name) toolCalls[tc.index].name = tc.function.name;
|
|
223
|
+
if (tc.function?.arguments) toolCalls[tc.index].arguments += tc.function.arguments;
|
|
224
|
+
}
|
|
225
|
+
const fr = chunk.choices?.[0]?.finish_reason;
|
|
226
|
+
if (fr === "length") stopReason = "max_tokens";
|
|
227
|
+
if (fr === "tool_calls") stopReason = "tool_use";
|
|
228
|
+
if (chunk.usage) outputTokens = chunk.usage.completion_tokens ?? 0;
|
|
229
|
+
} catch { /* skip malformed lines */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const contentBlocks = [];
|
|
233
|
+
if (reasoning) contentBlocks.push({ type: "thinking", thinking: reasoning });
|
|
234
|
+
|
|
235
|
+
const sortedIndices = Object.keys(toolCalls).sort((a, b) => Number(a) - Number(b));
|
|
236
|
+
for (const idx of sortedIndices) {
|
|
237
|
+
const tc = toolCalls[idx];
|
|
238
|
+
let input = {};
|
|
239
|
+
try { input = JSON.parse(tc.arguments); } catch { /* partial JSON */ }
|
|
240
|
+
contentBlocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (content) contentBlocks.push({ type: "text", text: content });
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
id,
|
|
247
|
+
type: "message",
|
|
248
|
+
role: "assistant",
|
|
249
|
+
model,
|
|
250
|
+
content: contentBlocks,
|
|
251
|
+
stop_reason: stopReason,
|
|
252
|
+
stop_sequence: null,
|
|
253
|
+
usage: {
|
|
254
|
+
input_tokens: inputTokens ?? 0,
|
|
255
|
+
output_tokens: outputTokens,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Convert a single OpenAI SSE line to one or more Anthropic SSE event strings.
|
|
261
|
+
function openAIChunkToAnthropicEvents(line, state) {
|
|
262
|
+
if (!line.startsWith("data: ")) return [];
|
|
263
|
+
|
|
264
|
+
if (line === "data: [DONE]") {
|
|
265
|
+
const events = [];
|
|
266
|
+
for (const idx of Object.keys(state.toolState).sort((a, b) => Number(a) - Number(b))) {
|
|
267
|
+
const ts = state.toolState[idx];
|
|
268
|
+
if (ts.opened && !ts.closed) {
|
|
269
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: ts.blockIdx }));
|
|
270
|
+
ts.closed = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (state.blockOpen) {
|
|
274
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
275
|
+
state.blockOpen = false;
|
|
276
|
+
}
|
|
277
|
+
events.push(fmt("message_delta", {
|
|
278
|
+
type: "message_delta",
|
|
279
|
+
delta: { stop_reason: state.finishReason || "end_turn", stop_sequence: null },
|
|
280
|
+
usage: { output_tokens: state.outputTokens },
|
|
281
|
+
}));
|
|
282
|
+
events.push(fmt("message_stop", { type: "message_stop" }));
|
|
283
|
+
return events;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let chunk;
|
|
287
|
+
try { chunk = JSON.parse(line.slice(6)); } catch { return []; }
|
|
288
|
+
|
|
289
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
290
|
+
if (!delta) return [];
|
|
291
|
+
|
|
292
|
+
const events = [];
|
|
293
|
+
const content = delta.content ?? "";
|
|
294
|
+
const reasoning = delta.reasoning_content ?? "";
|
|
295
|
+
const toolCalls = delta.tool_calls || [];
|
|
296
|
+
const fr = chunk.choices?.[0]?.finish_reason;
|
|
297
|
+
|
|
298
|
+
if (chunk.usage) state.outputTokens = chunk.usage.completion_tokens ?? state.outputTokens;
|
|
299
|
+
if (fr === "length") state.finishReason = "max_tokens";
|
|
300
|
+
if (fr === "stop") state.finishReason = "end_turn";
|
|
301
|
+
if (fr === "tool_calls") state.finishReason = "tool_use";
|
|
302
|
+
|
|
303
|
+
// Tool calls
|
|
304
|
+
for (const tc of toolCalls) {
|
|
305
|
+
const idx = tc.index;
|
|
306
|
+
if (!state.toolState[idx]) {
|
|
307
|
+
state.toolState[idx] = { id: "", name: "", arguments: "", opened: false, closed: false, blockIdx: -1 };
|
|
308
|
+
}
|
|
309
|
+
const ts = state.toolState[idx];
|
|
310
|
+
if (tc.id) ts.id = tc.id;
|
|
311
|
+
if (tc.function?.name) ts.name = tc.function.name;
|
|
312
|
+
if (tc.function?.arguments) ts.arguments += tc.function.arguments;
|
|
313
|
+
|
|
314
|
+
if (ts.name && !ts.opened) {
|
|
315
|
+
if (state.blockOpen) {
|
|
316
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
317
|
+
state.blockIndex++;
|
|
318
|
+
state.blockOpen = false;
|
|
319
|
+
state.thinkingOpen = false;
|
|
320
|
+
state.textOpen = false;
|
|
321
|
+
}
|
|
322
|
+
ts.blockIdx = state.blockIndex;
|
|
323
|
+
events.push(fmt("content_block_start", {
|
|
324
|
+
type: "content_block_start", index: state.blockIndex,
|
|
325
|
+
content_block: { type: "tool_use", id: ts.id, name: ts.name, input: {} },
|
|
326
|
+
}));
|
|
327
|
+
ts.opened = true;
|
|
328
|
+
state.blockOpen = true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (ts.opened && tc.function?.arguments) {
|
|
332
|
+
events.push(fmt("content_block_delta", {
|
|
333
|
+
type: "content_block_delta", index: ts.blockIdx,
|
|
334
|
+
delta: { type: "input_json_delta", partial_json: tc.function.arguments },
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Reasoning
|
|
340
|
+
if (reasoning && !state.thinkingOpen) {
|
|
341
|
+
if (state.blockOpen) {
|
|
342
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
343
|
+
state.blockIndex++;
|
|
344
|
+
state.blockOpen = false;
|
|
345
|
+
state.textOpen = false;
|
|
346
|
+
}
|
|
347
|
+
events.push(fmt("content_block_start", {
|
|
348
|
+
type: "content_block_start", index: state.blockIndex,
|
|
349
|
+
content_block: { type: "thinking", thinking: "" },
|
|
350
|
+
}));
|
|
351
|
+
state.thinkingOpen = true;
|
|
352
|
+
state.blockOpen = true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (reasoning) {
|
|
356
|
+
events.push(fmt("content_block_delta", {
|
|
357
|
+
type: "content_block_delta", index: state.blockIndex,
|
|
358
|
+
delta: { type: "thinking_delta", thinking: reasoning },
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Text
|
|
363
|
+
if (content && !state.textOpen) {
|
|
364
|
+
if (state.thinkingOpen) {
|
|
365
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: state.blockIndex }));
|
|
366
|
+
state.blockIndex++;
|
|
367
|
+
state.thinkingOpen = false;
|
|
368
|
+
state.blockOpen = false;
|
|
369
|
+
}
|
|
370
|
+
for (const idx of Object.keys(state.toolState).sort((a, b) => Number(a) - Number(b))) {
|
|
371
|
+
const ts = state.toolState[idx];
|
|
372
|
+
if (ts.opened && !ts.closed) {
|
|
373
|
+
events.push(fmt("content_block_stop", { type: "content_block_stop", index: ts.blockIdx }));
|
|
374
|
+
state.blockIndex++;
|
|
375
|
+
ts.closed = true;
|
|
376
|
+
state.blockOpen = false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!state.blockOpen) {
|
|
380
|
+
events.push(fmt("content_block_start", {
|
|
381
|
+
type: "content_block_start", index: state.blockIndex,
|
|
382
|
+
content_block: { type: "text", text: "" },
|
|
383
|
+
}));
|
|
384
|
+
state.textOpen = true;
|
|
385
|
+
state.blockOpen = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (content) {
|
|
390
|
+
events.push(fmt("content_block_delta", {
|
|
391
|
+
type: "content_block_delta", index: state.blockIndex,
|
|
392
|
+
delta: { type: "text_delta", text: content },
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return events;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function fmt(event, data) {
|
|
400
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Routes ─────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
function handleModels(req, res) {
|
|
406
|
+
const { models } = getModelCatalog(config);
|
|
407
|
+
const data = models.map((m) => ({
|
|
408
|
+
type: "model",
|
|
409
|
+
id: m.id,
|
|
410
|
+
display_name: m.name,
|
|
411
|
+
created_at: new Date().toISOString(),
|
|
412
|
+
}));
|
|
413
|
+
sendJSON(res, {
|
|
414
|
+
data,
|
|
415
|
+
has_more: false,
|
|
416
|
+
first_id: data[0]?.id ?? null,
|
|
417
|
+
last_id: data[data.length - 1]?.id ?? null,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function handleMessages(req, res) {
|
|
422
|
+
const startTime = Date.now();
|
|
423
|
+
const clientIp = resolveClientIp(req);
|
|
424
|
+
|
|
425
|
+
// Model identity isn't known until after conversion — keep these above
|
|
426
|
+
// record() so validation failures can still log safely (null = unknown).
|
|
427
|
+
let currentModelId = null;
|
|
428
|
+
let currentAnthropicModel = null;
|
|
429
|
+
|
|
430
|
+
// Exactly one observability entry per request (`via` marks cloud vs local).
|
|
431
|
+
let recorded = false;
|
|
432
|
+
function record(status, { lastMessage = null, messageCount = 0, error, via = "cloud", cloud_status, cloud_error } = {}) {
|
|
433
|
+
if (recorded) return;
|
|
434
|
+
recorded = true;
|
|
435
|
+
logRequest({
|
|
436
|
+
timestamp: new Date().toISOString(),
|
|
437
|
+
model: currentModelId, anthropic_model: currentAnthropicModel, status, via,
|
|
438
|
+
last_message: typeof lastMessage === "string"
|
|
439
|
+
? lastMessage.substring(0, 300)
|
|
440
|
+
: JSON.stringify(lastMessage)?.substring(0, 300) ?? "",
|
|
441
|
+
...(messageCount ? { message_count: messageCount } : {}),
|
|
442
|
+
...(error ? { error } : {}),
|
|
443
|
+
...(cloud_status ? { cloud_status, ...(cloud_error ? { cloud_error } : {}) } : {}),
|
|
444
|
+
});
|
|
445
|
+
logJsonl({ model: currentModelId, status, ip: clientIp, latencyMs: Date.now() - startTime, ...(via !== "cloud" ? { via } : {}), ...(error ? { error } : {}) });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// (logUpstreamErrorBody lives in lib/core.js — shared with openai.js)
|
|
449
|
+
|
|
450
|
+
let body;
|
|
451
|
+
try {
|
|
452
|
+
body = await readBody(req, config.MAX_BODY_BYTES);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
record(err.statusCode || 400, { error: "invalid_request" });
|
|
455
|
+
return sendErrorAnthropic(res, err.message, "invalid_request_error", err.statusCode || 400, "invalid_request");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const modelFieldError = validateModelField(body);
|
|
459
|
+
if (modelFieldError) {
|
|
460
|
+
record(400, { error: "invalid_request" });
|
|
461
|
+
return sendErrorAnthropic(res, modelFieldError.message, modelFieldError.type, modelFieldError.status, modelFieldError.code);
|
|
462
|
+
}
|
|
463
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
464
|
+
record(400, { error: "invalid_request" });
|
|
465
|
+
return sendErrorAnthropic(res, "messages must be a non-empty array", "invalid_request_error", 400, "invalid_messages");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const modelId = resolveModel(body.model);
|
|
469
|
+
const stream = body.stream === true; // Anthropic defaults to non-streaming
|
|
470
|
+
const openAIBody = anthropicToOpenAI(body, modelId);
|
|
471
|
+
const payloadError = validateChatPayload(openAIBody, config.MAX_MESSAGES);
|
|
472
|
+
if (payloadError) {
|
|
473
|
+
record(payloadError.statusCode, { error: "payload_too_large" });
|
|
474
|
+
return sendErrorAnthropic(res, payloadError.message, "invalid_request_error", payloadError.statusCode, "invalid_payload");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
currentModelId = modelId;
|
|
478
|
+
currentAnthropicModel = body.model;
|
|
479
|
+
log.info(`messages model=${body.model} -> ${modelId} stream=${stream}`);
|
|
480
|
+
|
|
481
|
+
const lastMsgForLog = () => lastMessagePreview(openAIBody.messages);
|
|
482
|
+
|
|
483
|
+
// Local AutoClaw WebSocket agent fallback (same trigger rules as the OpenAI
|
|
484
|
+
// entrypoint — this is what gives Anthropic its 402/403/5xx parity).
|
|
485
|
+
// Set when the cloud upstream rejects the request before fallback runs;
|
|
486
|
+
// consumed by record() so the terminal entry carries the cloud verdict.
|
|
487
|
+
let cloudEvidence = null;
|
|
488
|
+
const tryLocalAgent = () => {
|
|
489
|
+
if (!getLocalGatewayToken()) return Promise.resolve(false);
|
|
490
|
+
log.info(`Executing chat model=${modelId} via local AutoClaw WebSocket agent...`);
|
|
491
|
+
return new Promise((resolve) => {
|
|
492
|
+
let fullContent = "";
|
|
493
|
+
let streamedStart = false;
|
|
494
|
+
const startedAt = Date.now();
|
|
495
|
+
|
|
496
|
+
streamLocalGatewayAgent({
|
|
497
|
+
config,
|
|
498
|
+
modelId,
|
|
499
|
+
messages: openAIBody.messages,
|
|
500
|
+
onChunk: ({ delta }) => {
|
|
501
|
+
if (stream) {
|
|
502
|
+
if (!streamedStart) {
|
|
503
|
+
streamedStart = true;
|
|
504
|
+
res.writeHead(200, SSE_HEADERS);
|
|
505
|
+
res.write(fmt("message_start", {
|
|
506
|
+
type: "message_start",
|
|
507
|
+
message: {
|
|
508
|
+
id: `msg_${generateId()}`, type: "message", role: "assistant",
|
|
509
|
+
model: body.model, content: [], stop_reason: null, stop_sequence: null,
|
|
510
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
511
|
+
},
|
|
512
|
+
}));
|
|
513
|
+
res.write(fmt("content_block_start", {
|
|
514
|
+
type: "content_block_start", index: 0,
|
|
515
|
+
content_block: { type: "text", text: "" },
|
|
516
|
+
}));
|
|
517
|
+
}
|
|
518
|
+
res.write(fmt("content_block_delta", {
|
|
519
|
+
type: "content_block_delta", index: 0,
|
|
520
|
+
delta: { type: "text_delta", text: delta },
|
|
521
|
+
}));
|
|
522
|
+
} else {
|
|
523
|
+
fullContent += delta;
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
onEnd: ({ finishReason }) => {
|
|
527
|
+
if (stream) {
|
|
528
|
+
res.write(fmt("content_block_stop", { type: "content_block_stop", index: 0 }));
|
|
529
|
+
res.write(fmt("message_delta", {
|
|
530
|
+
type: "message_delta",
|
|
531
|
+
delta: { stop_reason: anthropicStopReason(finishReason), stop_sequence: null },
|
|
532
|
+
usage: { output_tokens: 0 },
|
|
533
|
+
}));
|
|
534
|
+
res.write(fmt("message_stop", { type: "message_stop" }));
|
|
535
|
+
res.end();
|
|
536
|
+
} else {
|
|
537
|
+
sendJSON(res, {
|
|
538
|
+
id: `msg_${generateId()}`,
|
|
539
|
+
type: "message",
|
|
540
|
+
role: "assistant",
|
|
541
|
+
model: body.model,
|
|
542
|
+
content: [{ type: "text", text: fullContent }],
|
|
543
|
+
stop_reason: anthropicStopReason(finishReason),
|
|
544
|
+
stop_sequence: null,
|
|
545
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
log.info(`chat model=${modelId} served via local agent (${Date.now() - startedAt}ms)`);
|
|
549
|
+
record(200, {
|
|
550
|
+
lastMessage: fullContent,
|
|
551
|
+
messageCount: openAIBody.messages?.length || 0,
|
|
552
|
+
via: "local",
|
|
553
|
+
...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
|
|
554
|
+
});
|
|
555
|
+
resolve(true);
|
|
556
|
+
},
|
|
557
|
+
onError: (err) => {
|
|
558
|
+
log.warn(`Local gateway execution failed: ${err.message}`);
|
|
559
|
+
const cls = classifyLocalAgentError(err, modelId);
|
|
560
|
+
permanentFailures.mark(modelId, cls);
|
|
561
|
+
if (res.headersSent) {
|
|
562
|
+
// Stream already started — close it rather than throwing a
|
|
563
|
+
// second writeHead onto a spent response.
|
|
564
|
+
try { res.end(); } catch (_) {}
|
|
565
|
+
record(cls.status, { error: `${cls.code} (mid-stream)`, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
|
|
566
|
+
} else {
|
|
567
|
+
record(cls.status, { error: cls.code, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
|
|
568
|
+
sendClassifiedErrorAnthropic(res, cls);
|
|
569
|
+
}
|
|
570
|
+
resolve(true);
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
try {
|
|
577
|
+
// PREFER_LOCAL=1 fast path — skip doomed cloud attempts entirely.
|
|
578
|
+
if (config.PREFER_LOCAL && getLocalGatewayToken()) {
|
|
579
|
+
if (await tryLocalAgent()) return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const cachedFailure = permanentFailures.get(modelId);
|
|
583
|
+
if (cachedFailure) {
|
|
584
|
+
log.info(`chat model=${modelId} short-circuited: ${cachedFailure.code} (recently confirmed)`);
|
|
585
|
+
record(cachedFailure.status, { error: cachedFailure.code });
|
|
586
|
+
return sendClassifiedErrorAnthropic(res, cachedFailure);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Cloud call with one retry on the flaky 400 "invalid request" hiccup;
|
|
590
|
+
// every >=400 body is buffered + logged (R1). Shared with openai.js.
|
|
591
|
+
const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
|
|
592
|
+
() => callUpstreamAnthropic(config, getClientHeaders(config), getToken, openAIBody, modelId),
|
|
593
|
+
modelId, permanentFailures, log,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const statusCode = upstreamRes.statusCode;
|
|
597
|
+
|
|
598
|
+
// Rotate token caches BEFORE deciding fallback so the very next request
|
|
599
|
+
// picks up the fresh JWT regardless of who serves this one.
|
|
600
|
+
if (statusCode === 401) invalidateAuth();
|
|
601
|
+
|
|
602
|
+
if (shouldFallbackToLocal(statusCode)) {
|
|
603
|
+
const cls = classifyUpstreamError(statusCode, upstreamErrBody, modelId);
|
|
604
|
+
if (cls.permanent) permanentFailures.mark(modelId, cls);
|
|
605
|
+
log.error(`Upstream error ${statusCode}:`, cls.message);
|
|
606
|
+
cloudEvidence = { status: statusCode, code: cls.code };
|
|
607
|
+
|
|
608
|
+
// The desktop gateway shares this AutoClaw account — quota walls stop
|
|
609
|
+
// it too, so don't march known-permanent failures into it.
|
|
610
|
+
if (!cls.permanent || !permanentFailures.get(modelId)) {
|
|
611
|
+
if (await tryLocalAgent()) return;
|
|
612
|
+
} else {
|
|
613
|
+
log.info(`Skipping local fallback for ${modelId}: ${cls.code} is account-wide`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
record(cls.status, {
|
|
617
|
+
lastMessage: lastMsgForLog(),
|
|
618
|
+
messageCount: openAIBody.messages?.length || 0,
|
|
619
|
+
error: cls.code,
|
|
620
|
+
...(statusCode !== cls.status ? { cloud_status: statusCode, cloud_error: cls.code } : {}),
|
|
621
|
+
});
|
|
622
|
+
return sendClassifiedErrorAnthropic(res, cls);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Success paths
|
|
626
|
+
record(statusCode, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0 });
|
|
627
|
+
|
|
628
|
+
if (stream) {
|
|
629
|
+
res.writeHead(200, SSE_HEADERS);
|
|
630
|
+
|
|
631
|
+
res.write(fmt("message_start", {
|
|
632
|
+
type: "message_start",
|
|
633
|
+
message: {
|
|
634
|
+
id: `msg_${generateId()}`, type: "message", role: "assistant",
|
|
635
|
+
model: modelId, content: [], stop_reason: null, stop_sequence: null,
|
|
636
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
637
|
+
},
|
|
638
|
+
}));
|
|
639
|
+
res.write(fmt("ping", { type: "ping" }));
|
|
640
|
+
|
|
641
|
+
const state = {
|
|
642
|
+
blockIndex: 0, blockOpen: false,
|
|
643
|
+
thinkingOpen: false, textOpen: false,
|
|
644
|
+
outputTokens: 0, finishReason: "end_turn",
|
|
645
|
+
toolState: {},
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
let buffer = "";
|
|
649
|
+
upstreamRes.on("data", (chunk) => {
|
|
650
|
+
buffer += chunk.toString();
|
|
651
|
+
const lines = buffer.split("\n");
|
|
652
|
+
buffer = lines.pop();
|
|
653
|
+
for (const line of lines) {
|
|
654
|
+
for (const e of openAIChunkToAnthropicEvents(line.trim(), state)) res.write(e);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
upstreamRes.on("end", () => {
|
|
659
|
+
if (buffer.trim()) {
|
|
660
|
+
for (const e of openAIChunkToAnthropicEvents(buffer.trim(), state)) res.write(e);
|
|
661
|
+
}
|
|
662
|
+
for (const e of openAIChunkToAnthropicEvents("data: [DONE]", state)) res.write(e);
|
|
663
|
+
res.end();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
upstreamRes.on("error", (err) => { log.error("Stream error:", err); res.end(); });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Non-stream: buffer everything into one Anthropic response object.
|
|
671
|
+
let raw = "";
|
|
672
|
+
upstreamRes.on("data", (c) => (raw += c));
|
|
673
|
+
upstreamRes.on("end", () => {
|
|
674
|
+
try {
|
|
675
|
+
const inputTokens = (body.messages?.length ?? 1) * 10; // rough estimate only
|
|
676
|
+
sendJSON(res, openAIChunksToAnthropic(raw, modelId, inputTokens));
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (!res.headersSent) sendErrorAnthropic(res, `Failed to parse upstream response: ${err.message}`, "api_error", 502, "upstream_parse_failed");
|
|
679
|
+
else { try { res.end(); } catch (_) {} }
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
} catch (err) {
|
|
683
|
+
const cls = classifyTransportError(err);
|
|
684
|
+
log.error(`messages model=${body.model} transport failure:`, cls.message);
|
|
685
|
+
if (!res.headersSent && shouldFallbackToLocal(cls.status)) {
|
|
686
|
+
if (await tryLocalAgent()) return;
|
|
687
|
+
}
|
|
688
|
+
if (res.headersSent) { try { res.end(); } catch (_) {} return; }
|
|
689
|
+
record(cls.status, { lastMessage: lastMsgForLog(), messageCount: openAIBody.messages?.length || 0, error: cls.code });
|
|
690
|
+
return sendClassifiedErrorAnthropic(res, cls);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Server
|
|
695
|
+
|
|
696
|
+
const server = createGatewayServer({
|
|
697
|
+
config, log, rateLimit,
|
|
698
|
+
sendError: sendErrorAnthropic,
|
|
699
|
+
routes: [
|
|
700
|
+
{ method: "GET", path: "/healthz", handler: makeHealthHandler(config, getToken) },
|
|
701
|
+
{ method: "GET", path: "/v1/models", handler: handleModels },
|
|
702
|
+
{ method: "POST", path: "/v1/messages", handler: handleMessages },
|
|
703
|
+
// Claude Code probes token counts pre-flight; we don't tokenize locally,
|
|
704
|
+
// so report zero rather than 404-ing the whole session handshake.
|
|
705
|
+
{ path: "/v1/messages/count_tokens", handler: (req, res) => sendJSON(res, { input_tokens: 0 }) },
|
|
706
|
+
],
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
installProcessGuards(log);
|
|
710
|
+
|
|
711
|
+
server.listen(config.PORT, config.HOST, () => {
|
|
712
|
+
printStartupBanner({
|
|
713
|
+
title: `🛸 AUTOCLAW GATEWAY PROXY (Anthropic Format v${VERSION})`,
|
|
714
|
+
rows: [
|
|
715
|
+
`Host : ${config.HOST}`,
|
|
716
|
+
`Port : ${config.PORT}`,
|
|
717
|
+
`Auth Key : ${config.PROXY_KEY}`,
|
|
718
|
+
`Rate Lim : ${config.RATE_LIMIT} req/s per IP`,
|
|
719
|
+
`Max Msgs : ${Number.isFinite(config.MAX_MESSAGES) ? `${config.MAX_MESSAGES} entries` : "unlimited"}`,
|
|
720
|
+
`Models : ${MODELS.map(m => m.id).join(", ")}`,
|
|
721
|
+
"",
|
|
722
|
+
"Claude Code CLI Base URL:",
|
|
723
|
+
`http://${config.HOST}:${config.PORT}`,
|
|
724
|
+
`Routing : opus→${tierTargets.opus} sonnet→${tierTargets.sonnet} haiku→${tierTargets.haiku}`,
|
|
725
|
+
],
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
try {
|
|
729
|
+
getToken();
|
|
730
|
+
console.log(" ✅ Token loaded - ready\n");
|
|
731
|
+
} catch (e) {
|
|
732
|
+
console.warn(` ⚠️ ${e.message}\n`);
|
|
733
|
+
}
|
|
734
|
+
});
|