···
8
8
# Enable/disable model reasoning traces (thinking) across requests + stream output.
9
9
ENABLE_THINKING=true
10
10
11
11
+
# ── Primary model (Anthropic Messages API) ───────────────────────────────────
12
12
+
# Set USE_ANTHROPIC=true to route primary requests through Anthropic's Messages API
13
13
+
# instead of an OpenAI-compatible endpoint. When enabled, OPENAI_API_KEY and MODEL
14
14
+
# are not required for the primary provider.
15
15
+
USE_ANTHROPIC=false
16
16
+
ANTHROPIC_BASE_URL=https://api.anthropic.com/v1
17
17
+
ANTHROPIC_API_KEY=
18
18
+
ANTHROPIC_MODEL=
19
19
+
ANTHROPIC_MAX_TOKENS=8192
20
20
+
ANTHROPIC_VERSION=2024-10-22
21
21
+
11
22
# ── Memory embeddings ────────────────────────────────────────────────────────
12
23
# Used for semantic memory reranking and low-information chatter suppression.
13
24
# The sqlite-vec table is fixed at 3072 dimensions; changing dimensions requires
···
13
13
"apps/*"
14
14
],
15
15
"dependencies": {
16
16
+
"@anthropic-ai/sdk": "^0.100.1",
16
17
"@fastify/static": "^9.1.3",
17
18
"@niri/chat-client": "*",
18
19
"@openrouter/sdk": "^0.12.21",
···
51
52
"vite": "^7.1.9"
52
53
}
53
54
},
55
55
+
"node_modules/@anthropic-ai/sdk": {
56
56
+
"version": "0.100.1",
57
57
+
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
58
58
+
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
59
59
+
"license": "MIT",
60
60
+
"dependencies": {
61
61
+
"json-schema-to-ts": "^3.1.1",
62
62
+
"standardwebhooks": "^1.0.0"
63
63
+
},
64
64
+
"bin": {
65
65
+
"anthropic-ai-sdk": "bin/cli"
66
66
+
},
67
67
+
"peerDependencies": {
68
68
+
"zod": "^3.25.0 || ^4.0.0"
69
69
+
},
70
70
+
"peerDependenciesMeta": {
71
71
+
"zod": {
72
72
+
"optional": true
73
73
+
}
74
74
+
}
75
75
+
},
54
76
"node_modules/@babel/code-frame": {
55
77
"version": "7.29.0",
56
78
"dev": true,
···
76
98
"version": "7.29.0",
77
99
"dev": true,
78
100
"license": "MIT",
101
101
+
"peer": true,
79
102
"dependencies": {
80
103
"@babel/code-frame": "^7.29.0",
81
104
"@babel/generator": "^7.29.0",
···
275
298
},
276
299
"peerDependencies": {
277
300
"@babel/core": "^7.0.0-0"
301
301
+
}
302
302
+
},
303
303
+
"node_modules/@babel/runtime": {
304
304
+
"version": "7.29.7",
305
305
+
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
306
306
+
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
307
307
+
"license": "MIT",
308
308
+
"engines": {
309
309
+
"node": ">=6.9.0"
278
310
}
279
311
},
280
312
"node_modules/@babel/template": {
···
1164
1196
"npm": ">=7.0.0"
1165
1197
}
1166
1198
},
1199
1199
+
"node_modules/@stablelib/base64": {
1200
1200
+
"version": "1.0.1",
1201
1201
+
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
1202
1202
+
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
1203
1203
+
"license": "MIT"
1204
1204
+
},
1167
1205
"node_modules/@types/babel__core": {
1168
1206
"version": "7.20.5",
1169
1207
"dev": true,
···
1248
1286
"node_modules/@types/node": {
1249
1287
"version": "25.5.2",
1250
1288
"license": "MIT",
1289
1289
+
"peer": true,
1251
1290
"dependencies": {
1252
1291
"undici-types": "~7.18.0"
1253
1292
}
···
1255
1294
"node_modules/@types/react": {
1256
1295
"version": "19.2.14",
1257
1296
"license": "MIT",
1297
1297
+
"peer": true,
1258
1298
"dependencies": {
1259
1299
"csstype": "^3.2.2"
1260
1300
}
···
1471
1511
}
1472
1512
],
1473
1513
"license": "MIT",
1514
1514
+
"peer": true,
1474
1515
"dependencies": {
1475
1516
"baseline-browser-mapping": "^2.10.12",
1476
1517
"caniuse-lite": "^1.0.30001782",
···
1904
1945
"fast-decode-uri-component": "^1.0.1"
1905
1946
}
1906
1947
},
1948
1948
+
"node_modules/fast-sha256": {
1949
1949
+
"version": "1.3.0",
1950
1950
+
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
1951
1951
+
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
1952
1952
+
"license": "Unlicense"
1953
1953
+
},
1907
1954
"node_modules/fast-uri": {
1908
1955
"version": "3.1.2",
1909
1956
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
···
2278
2325
"license": "MIT",
2279
2326
"dependencies": {
2280
2327
"dequal": "^2.0.3"
2328
2328
+
}
2329
2329
+
},
2330
2330
+
"node_modules/json-schema-to-ts": {
2331
2331
+
"version": "3.1.1",
2332
2332
+
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
2333
2333
+
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
2334
2334
+
"license": "MIT",
2335
2335
+
"dependencies": {
2336
2336
+
"@babel/runtime": "^7.18.3",
2337
2337
+
"ts-algebra": "^2.0.0"
2338
2338
+
},
2339
2339
+
"engines": {
2340
2340
+
"node": ">=16"
2281
2341
}
2282
2342
},
2283
2343
"node_modules/json-schema-traverse": {
···
3331
3391
"version": "4.0.4",
3332
3392
"dev": true,
3333
3393
"license": "MIT",
3394
3394
+
"peer": true,
3334
3395
"engines": {
3335
3396
"node": ">=12"
3336
3397
},
···
3472
3533
"node_modules/react": {
3473
3534
"version": "19.2.4",
3474
3535
"license": "MIT",
3536
3536
+
"peer": true,
3475
3537
"engines": {
3476
3538
"node": ">=0.10.0"
3477
3539
}
···
3937
3999
"win32"
3938
4000
]
3939
4001
},
4002
4002
+
"node_modules/standardwebhooks": {
4003
4003
+
"version": "1.0.0",
4004
4004
+
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
4005
4005
+
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
4006
4006
+
"license": "MIT",
4007
4007
+
"dependencies": {
4008
4008
+
"@stablelib/base64": "^1.0.0",
4009
4009
+
"fast-sha256": "^1.3.0"
4010
4010
+
}
4011
4011
+
},
3940
4012
"node_modules/statuses": {
3941
4013
"version": "2.0.2",
3942
4014
"license": "MIT",
···
4063
4135
"url": "https://github.com/sponsors/wooorm"
4064
4136
}
4065
4137
},
4138
4138
+
"node_modules/ts-algebra": {
4139
4139
+
"version": "2.0.0",
4140
4140
+
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
4141
4141
+
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
4142
4142
+
"license": "MIT"
4143
4143
+
},
4066
4144
"node_modules/ts-mixer": {
4067
4145
"version": "6.0.4",
4068
4146
"license": "MIT"
···
4075
4153
"version": "4.21.0",
4076
4154
"dev": true,
4077
4155
"license": "MIT",
4156
4156
+
"peer": true,
4078
4157
"dependencies": {
4079
4158
"esbuild": "~0.27.0",
4080
4159
"get-tsconfig": "^4.7.5"
···
4270
4349
"version": "7.3.2",
4271
4350
"dev": true,
4272
4351
"license": "MIT",
4352
4352
+
"peer": true,
4273
4353
"dependencies": {
4274
4354
"esbuild": "^0.27.0",
4275
4355
"fdir": "^6.5.0",
···
28
28
"author": "",
29
29
"license": "ISC",
30
30
"dependencies": {
31
31
+
"@anthropic-ai/sdk": "^0.100.1",
31
32
"@fastify/static": "^9.1.3",
32
33
"@niri/chat-client": "*",
33
34
"@openrouter/sdk": "^0.12.21",
···
1
1
+
import Anthropic from "@anthropic-ai/sdk"
2
2
+
import OpenAI from "openai"
3
3
+
import { recordMetric } from "../metrics"
4
4
+
import { emit } from "../stream"
5
5
+
import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared"
6
6
+
7
7
+
// ── config ──────────────────────────────────────────────────────────────
8
8
+
9
9
+
export const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
10
10
+
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? ""
11
11
+
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? ""
12
12
+
export const ANTHROPIC_MAX_TOKENS = Math.max(1, Number.parseInt(process.env.ANTHROPIC_MAX_TOKENS ?? "8192", 10)) || 8192
13
13
+
export const ANTHROPIC_VERSION = process.env.ANTHROPIC_VERSION ?? "2024-10-22"
14
14
+
15
15
+
const client = new Anthropic({
16
16
+
apiKey: ANTHROPIC_API_KEY,
17
17
+
baseURL: ANTHROPIC_BASE_URL,
18
18
+
defaultHeaders: { "Anthropic-Version": ANTHROPIC_VERSION },
19
19
+
})
20
20
+
21
21
+
// ── errors ──────────────────────────────────────────────────────────────
22
22
+
23
23
+
function wrapAnthropicError(err: unknown): InstanceType<typeof OpenAI.APIError> {
24
24
+
if (err instanceof Anthropic.APIError) {
25
25
+
return new OpenAI.APIError(err.status, err.error, err.message, err.headers ?? new Headers())
26
26
+
}
27
27
+
if (err instanceof Error) {
28
28
+
return new OpenAI.APIError(undefined, { type: "unknown_error" }, err.message, new Headers())
29
29
+
}
30
30
+
return new OpenAI.APIError(undefined, { type: "unknown_error" }, String(err), new Headers())
31
31
+
}
32
32
+
33
33
+
// ── message conversion (OpenAI → Anthropic) ─────────────────────────────
34
34
+
35
35
+
type AnthropicMessageParam = Anthropic.MessageParam
36
36
+
37
37
+
type AnthropicContentBlock =
38
38
+
| Anthropic.TextBlockParam
39
39
+
| Anthropic.ImageBlockParam
40
40
+
| Anthropic.ToolResultBlockParam
41
41
+
| Anthropic.ToolUseBlockParam
42
42
+
43
43
+
function convertImageUrl(imageUrl: { url: string; detail?: string }): Anthropic.ImageBlockParam | null {
44
44
+
const url = imageUrl.url
45
45
+
if (url.startsWith("data:")) {
46
46
+
const match = url.match(/^data:([^;]+);base64,(.+)$/)
47
47
+
if (match) {
48
48
+
const mediaType = match[1]
49
49
+
const data = match[2]
50
50
+
if (mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/gif" || mediaType === "image/webp") {
51
51
+
return { type: "image", source: { type: "base64", media_type: mediaType, data } }
52
52
+
}
53
53
+
}
54
54
+
}
55
55
+
console.warn(`[anthropic] skipping unsupported image URL: ${url.slice(0, 80)}...`)
56
56
+
return null
57
57
+
}
58
58
+
59
59
+
function extractUserContentBlocks(
60
60
+
msg: OpenAI.Chat.ChatCompletionMessageParam,
61
61
+
): AnthropicContentBlock[] {
62
62
+
const content = msg.content
63
63
+
if (typeof content === "string") {
64
64
+
return [{ type: "text" as const, text: content }]
65
65
+
}
66
66
+
if (!Array.isArray(content)) {
67
67
+
return [{ type: "text" as const, text: JSON.stringify(content) }]
68
68
+
}
69
69
+
70
70
+
const blocks: AnthropicContentBlock[] = []
71
71
+
for (const part of content) {
72
72
+
if (part.type === "text") {
73
73
+
blocks.push({ type: "text" as const, text: part.text })
74
74
+
} else if (part.type === "image_url") {
75
75
+
const imageBlock = convertImageUrl(part.image_url)
76
76
+
if (imageBlock) blocks.push(imageBlock)
77
77
+
}
78
78
+
}
79
79
+
return blocks
80
80
+
}
81
81
+
82
82
+
function extractAssistantContentBlocks(
83
83
+
msg: OpenAI.Chat.ChatCompletionAssistantMessageParam,
84
84
+
): AnthropicContentBlock[] {
85
85
+
const blocks: AnthropicContentBlock[] = []
86
86
+
87
87
+
if (typeof msg.content === "string" && msg.content) {
88
88
+
blocks.push({ type: "text" as const, text: msg.content })
89
89
+
} else if (Array.isArray(msg.content)) {
90
90
+
for (const part of msg.content) {
91
91
+
if (part.type === "text") {
92
92
+
blocks.push({ type: "text" as const, text: part.text })
93
93
+
}
94
94
+
}
95
95
+
}
96
96
+
97
97
+
if (msg.tool_calls) {
98
98
+
for (const tc of msg.tool_calls) {
99
99
+
if (tc.type === "function") {
100
100
+
let input: Record<string, unknown>
101
101
+
try {
102
102
+
input = JSON.parse(tc.function.arguments)
103
103
+
} catch {
104
104
+
input = {}
105
105
+
}
106
106
+
blocks.push({
107
107
+
type: "tool_use" as const,
108
108
+
id: tc.id,
109
109
+
name: tc.function.name,
110
110
+
input,
111
111
+
})
112
112
+
}
113
113
+
}
114
114
+
}
115
115
+
116
116
+
return blocks
117
117
+
}
118
118
+
119
119
+
function toAnthropicMessages(
120
120
+
messages: OpenAI.Chat.ChatCompletionMessageParam[],
121
121
+
): { system?: string; messages: AnthropicMessageParam[] } {
122
122
+
const systemParts: string[] = []
123
123
+
const out: AnthropicMessageParam[] = []
124
124
+
125
125
+
for (const msg of messages) {
126
126
+
if (msg.role === "system") {
127
127
+
if (typeof msg.content === "string") {
128
128
+
systemParts.push(msg.content)
129
129
+
} else if (Array.isArray(msg.content)) {
130
130
+
for (const part of msg.content) {
131
131
+
if (part.type === "text") systemParts.push(part.text)
132
132
+
}
133
133
+
}
134
134
+
continue
135
135
+
}
136
136
+
137
137
+
if (msg.role === "user") {
138
138
+
const blocks = extractUserContentBlocks(msg)
139
139
+
const last = out[out.length - 1]
140
140
+
if (last && last.role === "user") {
141
141
+
const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }]
142
142
+
last.content = [...existing, ...blocks]
143
143
+
} else {
144
144
+
out.push({ role: "user", content: blocks })
145
145
+
}
146
146
+
continue
147
147
+
}
148
148
+
149
149
+
if (msg.role === "assistant") {
150
150
+
const blocks = extractAssistantContentBlocks(msg as OpenAI.Chat.ChatCompletionAssistantMessageParam)
151
151
+
const last = out[out.length - 1]
152
152
+
if (last && last.role === "assistant") {
153
153
+
const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }]
154
154
+
last.content = [...existing, ...blocks]
155
155
+
} else {
156
156
+
out.push({ role: "assistant", content: blocks })
157
157
+
}
158
158
+
continue
159
159
+
}
160
160
+
161
161
+
if (msg.role === "tool") {
162
162
+
const toolMsg = msg as OpenAI.Chat.ChatCompletionToolMessageParam
163
163
+
const block: Anthropic.ToolResultBlockParam = {
164
164
+
type: "tool_result",
165
165
+
tool_use_id: toolMsg.tool_call_id,
166
166
+
content: typeof toolMsg.content === "string" ? toolMsg.content : JSON.stringify(toolMsg.content),
167
167
+
}
168
168
+
const last = out[out.length - 1]
169
169
+
if (last && last.role === "user") {
170
170
+
const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }]
171
171
+
last.content = [...existing, block]
172
172
+
} else {
173
173
+
out.push({ role: "user", content: [block] })
174
174
+
}
175
175
+
continue
176
176
+
}
177
177
+
}
178
178
+
179
179
+
return {
180
180
+
system: systemParts.join("\n\n") || undefined,
181
181
+
messages: out,
182
182
+
}
183
183
+
}
184
184
+
185
185
+
// ── tool conversion ─────────────────────────────────────────────────────
186
186
+
187
187
+
function toAnthropicTools(tools: OpenAI.Chat.ChatCompletionTool[]): Anthropic.Tool[] {
188
188
+
return tools
189
189
+
.filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function"; function: { name: string; description?: string; parameters?: unknown } } => tool.type === "function")
190
190
+
.map((tool) => ({
191
191
+
name: tool.function.name,
192
192
+
description: tool.function.description ?? "",
193
193
+
input_schema: (tool.function.parameters ?? { type: "object", properties: {} }) as Anthropic.Tool.InputSchema,
194
194
+
}))
195
195
+
}
196
196
+
197
197
+
function toAnthropicToolChoice(
198
198
+
toolChoice: "required" | "auto" | "none",
199
199
+
): Anthropic.ToolChoice | undefined {
200
200
+
if (toolChoice === "required") return { type: "any" }
201
201
+
if (toolChoice === "auto") return { type: "auto" }
202
202
+
return undefined
203
203
+
}
204
204
+
205
205
+
// ── streaming conversion (Anthropic SSE → OpenAI chunks) ────────────────
206
206
+
207
207
+
function makeChunk(partial: Omit<OpenAI.Chat.ChatCompletionChunk.Choice.Delta, "role" | "content" | "tool_calls"> & {
208
208
+
role?: "assistant"
209
209
+
content?: string | null
210
210
+
tool_calls?: OpenAI.Chat.ChatCompletionChunk.Choice.Delta.ToolCall[]
211
211
+
}): OpenAI.Chat.ChatCompletionChunk {
212
212
+
return {
213
213
+
id: "anthropic-chunk",
214
214
+
object: "chat.completion.chunk",
215
215
+
created: Math.floor(Date.now() / 1000),
216
216
+
model: "anthropic",
217
217
+
choices: [{ delta: partial, finish_reason: null, index: 0 }],
218
218
+
}
219
219
+
}
220
220
+
221
221
+
async function* anthropicStreamToOpenAI(
222
222
+
stream: AsyncIterable<Anthropic.Messages.RawMessageStreamEvent>,
223
223
+
): AsyncGenerator<OpenAI.Chat.ChatCompletionChunk> {
224
224
+
const toolBuffers = new Map<number, { id: string; name: string; arguments: string }>()
225
225
+
let usage: { prompt_tokens: number; completion_tokens: number } | undefined
226
226
+
227
227
+
for await (const event of stream) {
228
228
+
switch (event.type) {
229
229
+
case "message_start": {
230
230
+
const inputTokens = event.message.usage?.input_tokens
231
231
+
if (inputTokens != null) {
232
232
+
usage = { prompt_tokens: inputTokens, completion_tokens: 0 }
233
233
+
}
234
234
+
yield makeChunk({ role: "assistant" })
235
235
+
break
236
236
+
}
237
237
+
238
238
+
case "content_block_start": {
239
239
+
const index = event.index
240
240
+
const block = event.content_block
241
241
+
if (block.type === "tool_use") {
242
242
+
toolBuffers.set(index, { id: block.id, name: block.name, arguments: "" })
243
243
+
yield makeChunk({
244
244
+
tool_calls: [
245
245
+
{
246
246
+
index,
247
247
+
id: block.id,
248
248
+
type: "function",
249
249
+
function: { name: block.name, arguments: "" },
250
250
+
},
251
251
+
],
252
252
+
})
253
253
+
}
254
254
+
break
255
255
+
}
256
256
+
257
257
+
case "content_block_delta": {
258
258
+
const index = event.index
259
259
+
const delta = event.delta
260
260
+
if (delta.type === "text_delta") {
261
261
+
yield makeChunk({ content: delta.text })
262
262
+
} else if (delta.type === "input_json_delta") {
263
263
+
const tool = toolBuffers.get(index)
264
264
+
if (tool) {
265
265
+
tool.arguments += delta.partial_json
266
266
+
yield makeChunk({
267
267
+
tool_calls: [
268
268
+
{
269
269
+
index,
270
270
+
function: { arguments: delta.partial_json },
271
271
+
},
272
272
+
],
273
273
+
})
274
274
+
}
275
275
+
}
276
276
+
break
277
277
+
}
278
278
+
279
279
+
case "message_delta": {
280
280
+
const outputTokens = event.usage?.output_tokens
281
281
+
if (outputTokens != null && usage) {
282
282
+
usage.completion_tokens = outputTokens
283
283
+
}
284
284
+
break
285
285
+
}
286
286
+
287
287
+
case "message_stop":
288
288
+
break
289
289
+
290
290
+
default:
291
291
+
break
292
292
+
}
293
293
+
}
294
294
+
295
295
+
if (usage) {
296
296
+
yield {
297
297
+
id: "anthropic-usage",
298
298
+
object: "chat.completion.chunk",
299
299
+
created: Math.floor(Date.now() / 1000),
300
300
+
model: "anthropic",
301
301
+
choices: [],
302
302
+
usage: {
303
303
+
prompt_tokens: usage.prompt_tokens,
304
304
+
completion_tokens: usage.completion_tokens,
305
305
+
total_tokens: usage.prompt_tokens + usage.completion_tokens,
306
306
+
},
307
307
+
}
308
308
+
}
309
309
+
}
310
310
+
311
311
+
// ── stream consumer ─────────────────────────────────────────────────────
312
312
+
313
313
+
async function consumeAnthropicStream(
314
314
+
stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>,
315
315
+
): Promise<CompletionTurnResult> {
316
316
+
const startedAt = Date.now()
317
317
+
const contentParts: string[] = []
318
318
+
const streamedToolCalls = new Map<number, ToolCallAssembly>()
319
319
+
let usage: OpenAI.Completions.CompletionUsage | undefined
320
320
+
let emittedText = false
321
321
+
let emittedThinking = false
322
322
+
323
323
+
for await (const chunk of stream) {
324
324
+
if (chunk.usage) usage = chunk.usage
325
325
+
326
326
+
const choice = chunk.choices[0]
327
327
+
if (!choice) continue
328
328
+
329
329
+
const delta = choice.delta as OpenAI.Chat.ChatCompletionChunk.Choice.Delta & {
330
330
+
reasoning_content?: string
331
331
+
}
332
332
+
333
333
+
if (typeof delta.content === "string" && delta.content.length > 0) {
334
334
+
contentParts.push(delta.content)
335
335
+
emit({ type: "text", text: delta.content })
336
336
+
emittedText = true
337
337
+
}
338
338
+
339
339
+
if (!Array.isArray(delta.tool_calls)) continue
340
340
+
341
341
+
for (const partial of delta.tool_calls) {
342
342
+
const index = partial.index ?? 0
343
343
+
const existing = streamedToolCalls.get(index) ?? {
344
344
+
id: partial.id ?? `call_${index}`,
345
345
+
type: "function" as const,
346
346
+
function: { name: "", arguments: "" },
347
347
+
}
348
348
+
349
349
+
if (partial.id) existing.id = partial.id
350
350
+
if (partial.type === "function") existing.type = "function"
351
351
+
if (partial.function?.name) existing.function.name += partial.function.name
352
352
+
if (partial.function?.arguments) existing.function.arguments += partial.function.arguments
353
353
+
streamedToolCalls.set(index, existing)
354
354
+
}
355
355
+
}
356
356
+
357
357
+
const finalToolCalls =
358
358
+
streamedToolCalls.size > 0
359
359
+
? [...streamedToolCalls.entries()]
360
360
+
.sort((a, b) => a[0] - b[0])
361
361
+
.map(([, toolCall]) => toolCall)
362
362
+
: []
363
363
+
364
364
+
const message: OpenAI.Chat.ChatCompletionMessage = {
365
365
+
role: "assistant",
366
366
+
content: contentParts.length > 0 ? contentParts.join("") : null,
367
367
+
refusal: null,
368
368
+
...(finalToolCalls.length > 0 ? { tool_calls: finalToolCalls } : {}),
369
369
+
}
370
370
+
371
371
+
const elapsedMs = Math.max(0, Date.now() - startedAt)
372
372
+
const tokensPerSecond =
373
373
+
usage && elapsedMs > 0 ? usage.completion_tokens / (elapsedMs / 1000) : undefined
374
374
+
375
375
+
return {
376
376
+
message,
377
377
+
usage,
378
378
+
emittedText,
379
379
+
emittedThinking,
380
380
+
bufferedThinking: "",
381
381
+
elapsedMs,
382
382
+
tokensPerSecond,
383
383
+
}
384
384
+
}
385
385
+
386
386
+
// ── public API ──────────────────────────────────────────────────────────
387
387
+
388
388
+
export async function createAnthropicCompletion(
389
389
+
request: CompletionRequest,
390
390
+
): Promise<CompletionTurnResult> {
391
391
+
const { system, messages } = toAnthropicMessages(request.messages)
392
392
+
393
393
+
const body: Anthropic.MessageCreateParams = {
394
394
+
model: request.model,
395
395
+
max_tokens: ANTHROPIC_MAX_TOKENS,
396
396
+
messages,
397
397
+
...(system ? { system } : {}),
398
398
+
}
399
399
+
400
400
+
if (request.tools.length > 0 && request.tool_choice !== "none") {
401
401
+
body.tools = toAnthropicTools(request.tools)
402
402
+
const tc = toAnthropicToolChoice(request.tool_choice)
403
403
+
if (tc) body.tool_choice = tc
404
404
+
}
405
405
+
406
406
+
const promptMetricId = recordMetric({ type: "prompt", messages: request.messages })
407
407
+
408
408
+
try {
409
409
+
const stream = await client.messages.create({ ...body, stream: true })
410
410
+
const convertedStream = anthropicStreamToOpenAI(stream)
411
411
+
const result = await consumeAnthropicStream(convertedStream)
412
412
+
413
413
+
recordMetric({
414
414
+
type: "prompt_response",
415
415
+
promptMetricId: promptMetricId ?? undefined,
416
416
+
model: request.model,
417
417
+
toolChoice: request.tool_choice,
418
418
+
messages: request.messages,
419
419
+
response: result.message,
420
420
+
usage: result.usage,
421
421
+
})
422
422
+
423
423
+
return result
424
424
+
} catch (err) {
425
425
+
throw wrapAnthropicError(err)
426
426
+
}
427
427
+
}
···
10
10
FALLBACK_BASE,
11
11
FALLBACK_MODEL,
12
12
FALLBACK_TOOL_CHOICE,
13
13
+
ANTHROPIC_MODEL,
13
14
MODEL,
14
15
PRIMARY_TOOL_CHOICE,
15
16
SUMMARY_MODEL,
16
17
TOOLS,
18
18
+
USE_ANTHROPIC,
17
19
USE_FALLBACK,
18
20
apiErrorDetails,
19
21
client,
···
33
35
summaryClient,
34
36
summarizeConversationViaLLM,
35
37
} from "./util"
38
38
+
import { createAnthropicCompletion } from "./anthropic"
36
39
import { assistantContentText } from "./loop-content"
37
40
import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared"
38
41
···
519
522
}
520
523
521
524
async function createPrimaryCompletion(messages: OpenAI.Chat.ChatCompletionMessageParam[]): Promise<CompletionTurnResult> {
525
525
+
if (USE_ANTHROPIC) {
526
526
+
return createAnthropicCompletion({
527
527
+
model: ANTHROPIC_MODEL,
528
528
+
messages,
529
529
+
tools: TOOLS,
530
530
+
tool_choice: PRIMARY_TOOL_CHOICE,
531
531
+
})
532
532
+
}
533
533
+
522
534
const request: CompletionRequest = {
523
535
model: MODEL,
524
536
messages,
···
61
61
export const SUMMARY_MODEL = process.env.SUMMARY_MODEL ?? ""
62
62
export const PRIMARY_TOOL_CHOICE = parseToolChoiceEnv(process.env.PRIMARY_TOOL_CHOICE ?? process.env.TOOL_CHOICE, "required")
63
63
export const FALLBACK_TOOL_CHOICE = parseToolChoiceEnv(process.env.FALLBACK_TOOL_CHOICE, "required")
64
64
+
65
65
+
export const USE_ANTHROPIC = parseBooleanEnv(process.env.USE_ANTHROPIC, false)
66
66
+
export const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1"
67
67
+
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? ""
68
68
+
export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? ""
69
69
+
export const ANTHROPIC_MAX_TOKENS = Math.max(1, Number.parseInt(process.env.ANTHROPIC_MAX_TOKENS ?? "8192", 10)) || 8192
70
70
+
export const ANTHROPIC_VERSION = process.env.ANTHROPIC_VERSION ?? "2024-10-22"
64
71
const FALLBACK_N_CTX = parseInt(process.env.FALLBACK_N_CTX ?? process.env.LMSTUDIO_N_CTX ?? "4096")
65
72
const FALLBACK_CONTEXT_MARGIN = parseInt(process.env.FALLBACK_CONTEXT_MARGIN ?? process.env.LMSTUDIO_CONTEXT_MARGIN ?? "256")
66
73
const FALLBACK_HARD_OVERFLOW_TOKENS = parseInt(
···
96
103
["User-Agent", openAIUserAgent(process.env.SUMMARY_OPENAI_USER_AGENT)],
97
104
])
98
105
99
99
-
if (!USE_FALLBACK && !MODEL) {
100
100
-
throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local).")
106
106
+
if (!USE_FALLBACK && !USE_ANTHROPIC && !MODEL) {
107
107
+
throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local) or Anthropic is used (USE_ANTHROPIC=true).")
108
108
+
}
109
109
+
110
110
+
if (!USE_FALLBACK && !USE_ANTHROPIC && !process.env.OPENAI_API_KEY) {
111
111
+
throw new Error("OPENAI_API_KEY is required unless fallback is forced (NIRI_ENV=local) or Anthropic is used (USE_ANTHROPIC=true).")
112
112
+
}
113
113
+
114
114
+
if (USE_ANTHROPIC && !ANTHROPIC_API_KEY) {
115
115
+
throw new Error("ANTHROPIC_API_KEY is required when USE_ANTHROPIC=true.")
101
116
}
102
117
103
103
-
if (!USE_FALLBACK && !process.env.OPENAI_API_KEY) {
104
104
-
throw new Error("OPENAI_API_KEY is required unless fallback is forced (NIRI_ENV=local).")
118
118
+
if (USE_ANTHROPIC && !ANTHROPIC_MODEL) {
119
119
+
throw new Error("ANTHROPIC_MODEL is required when USE_ANTHROPIC=true.")
105
120
}
106
121
107
122
if (USE_FALLBACK && !fallbackApiKey) {
···
139
154
})
140
155
: null
141
156
142
142
-
console.log(`[config] primary=${MODEL} @ ${API_BASE}`)
157
157
+
if (USE_ANTHROPIC) {
158
158
+
console.log(`[config] primary=${ANTHROPIC_MODEL} @ ${ANTHROPIC_BASE_URL} (anthropic)`)
159
159
+
} else {
160
160
+
console.log(`[config] primary=${MODEL} @ ${API_BASE}`)
161
161
+
}
143
162
console.log(`[config] fallback=${FALLBACK_MODEL} @ ${FALLBACK_BASE}`)
144
163
if (summaryClient) console.log(`[config] summary=${SUMMARY_MODEL} @ ${SUMMARY_BASE}`)
145
164
console.log(`[config] env=${NIRI_ENV} use_fallback=${USE_FALLBACK}`)