This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

anthropic converterg

+555 -5
+11
.env.example
··· 8 8 # Enable/disable model reasoning traces (thinking) across requests + stream output. 9 9 ENABLE_THINKING=true 10 10 11 + # ── Primary model (Anthropic Messages API) ─────────────────────────────────── 12 + # Set USE_ANTHROPIC=true to route primary requests through Anthropic's Messages API 13 + # instead of an OpenAI-compatible endpoint. When enabled, OPENAI_API_KEY and MODEL 14 + # are not required for the primary provider. 15 + USE_ANTHROPIC=false 16 + ANTHROPIC_BASE_URL=https://api.anthropic.com/v1 17 + ANTHROPIC_API_KEY= 18 + ANTHROPIC_MODEL= 19 + ANTHROPIC_MAX_TOKENS=8192 20 + ANTHROPIC_VERSION=2024-10-22 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
+80
package-lock.json
··· 13 13 "apps/*" 14 14 ], 15 15 "dependencies": { 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 + "node_modules/@anthropic-ai/sdk": { 56 + "version": "0.100.1", 57 + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz", 58 + "integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==", 59 + "license": "MIT", 60 + "dependencies": { 61 + "json-schema-to-ts": "^3.1.1", 62 + "standardwebhooks": "^1.0.0" 63 + }, 64 + "bin": { 65 + "anthropic-ai-sdk": "bin/cli" 66 + }, 67 + "peerDependencies": { 68 + "zod": "^3.25.0 || ^4.0.0" 69 + }, 70 + "peerDependenciesMeta": { 71 + "zod": { 72 + "optional": true 73 + } 74 + } 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 + "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 + } 302 + }, 303 + "node_modules/@babel/runtime": { 304 + "version": "7.29.7", 305 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", 306 + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", 307 + "license": "MIT", 308 + "engines": { 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 + "node_modules/@stablelib/base64": { 1200 + "version": "1.0.1", 1201 + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", 1202 + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", 1203 + "license": "MIT" 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 + "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 + "peer": true, 1258 1298 "dependencies": { 1259 1299 "csstype": "^3.2.2" 1260 1300 } ··· 1471 1511 } 1472 1512 ], 1473 1513 "license": "MIT", 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 + "node_modules/fast-sha256": { 1949 + "version": "1.3.0", 1950 + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", 1951 + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", 1952 + "license": "Unlicense" 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 + } 2329 + }, 2330 + "node_modules/json-schema-to-ts": { 2331 + "version": "3.1.1", 2332 + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", 2333 + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", 2334 + "license": "MIT", 2335 + "dependencies": { 2336 + "@babel/runtime": "^7.18.3", 2337 + "ts-algebra": "^2.0.0" 2338 + }, 2339 + "engines": { 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 + "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 + "peer": true, 3475 3537 "engines": { 3476 3538 "node": ">=0.10.0" 3477 3539 } ··· 3937 3999 "win32" 3938 4000 ] 3939 4001 }, 4002 + "node_modules/standardwebhooks": { 4003 + "version": "1.0.0", 4004 + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", 4005 + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", 4006 + "license": "MIT", 4007 + "dependencies": { 4008 + "@stablelib/base64": "^1.0.0", 4009 + "fast-sha256": "^1.3.0" 4010 + } 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 + "node_modules/ts-algebra": { 4139 + "version": "2.0.0", 4140 + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", 4141 + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", 4142 + "license": "MIT" 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 + "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 + "peer": true, 4273 4353 "dependencies": { 4274 4354 "esbuild": "^0.27.0", 4275 4355 "fdir": "^6.5.0",
+1
package.json
··· 28 28 "author": "", 29 29 "license": "ISC", 30 30 "dependencies": { 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",
+427
src/runner/anthropic.ts
··· 1 + import Anthropic from "@anthropic-ai/sdk" 2 + import OpenAI from "openai" 3 + import { recordMetric } from "../metrics" 4 + import { emit } from "../stream" 5 + import type { CompletionRequest, CompletionTurnResult, ToolCallAssembly } from "./loop-shared" 6 + 7 + // ── config ────────────────────────────────────────────────────────────── 8 + 9 + export const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1" 10 + export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? "" 11 + export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "" 12 + export const ANTHROPIC_MAX_TOKENS = Math.max(1, Number.parseInt(process.env.ANTHROPIC_MAX_TOKENS ?? "8192", 10)) || 8192 13 + export const ANTHROPIC_VERSION = process.env.ANTHROPIC_VERSION ?? "2024-10-22" 14 + 15 + const client = new Anthropic({ 16 + apiKey: ANTHROPIC_API_KEY, 17 + baseURL: ANTHROPIC_BASE_URL, 18 + defaultHeaders: { "Anthropic-Version": ANTHROPIC_VERSION }, 19 + }) 20 + 21 + // ── errors ────────────────────────────────────────────────────────────── 22 + 23 + function wrapAnthropicError(err: unknown): InstanceType<typeof OpenAI.APIError> { 24 + if (err instanceof Anthropic.APIError) { 25 + return new OpenAI.APIError(err.status, err.error, err.message, err.headers ?? new Headers()) 26 + } 27 + if (err instanceof Error) { 28 + return new OpenAI.APIError(undefined, { type: "unknown_error" }, err.message, new Headers()) 29 + } 30 + return new OpenAI.APIError(undefined, { type: "unknown_error" }, String(err), new Headers()) 31 + } 32 + 33 + // ── message conversion (OpenAI → Anthropic) ───────────────────────────── 34 + 35 + type AnthropicMessageParam = Anthropic.MessageParam 36 + 37 + type AnthropicContentBlock = 38 + | Anthropic.TextBlockParam 39 + | Anthropic.ImageBlockParam 40 + | Anthropic.ToolResultBlockParam 41 + | Anthropic.ToolUseBlockParam 42 + 43 + function convertImageUrl(imageUrl: { url: string; detail?: string }): Anthropic.ImageBlockParam | null { 44 + const url = imageUrl.url 45 + if (url.startsWith("data:")) { 46 + const match = url.match(/^data:([^;]+);base64,(.+)$/) 47 + if (match) { 48 + const mediaType = match[1] 49 + const data = match[2] 50 + if (mediaType === "image/png" || mediaType === "image/jpeg" || mediaType === "image/gif" || mediaType === "image/webp") { 51 + return { type: "image", source: { type: "base64", media_type: mediaType, data } } 52 + } 53 + } 54 + } 55 + console.warn(`[anthropic] skipping unsupported image URL: ${url.slice(0, 80)}...`) 56 + return null 57 + } 58 + 59 + function extractUserContentBlocks( 60 + msg: OpenAI.Chat.ChatCompletionMessageParam, 61 + ): AnthropicContentBlock[] { 62 + const content = msg.content 63 + if (typeof content === "string") { 64 + return [{ type: "text" as const, text: content }] 65 + } 66 + if (!Array.isArray(content)) { 67 + return [{ type: "text" as const, text: JSON.stringify(content) }] 68 + } 69 + 70 + const blocks: AnthropicContentBlock[] = [] 71 + for (const part of content) { 72 + if (part.type === "text") { 73 + blocks.push({ type: "text" as const, text: part.text }) 74 + } else if (part.type === "image_url") { 75 + const imageBlock = convertImageUrl(part.image_url) 76 + if (imageBlock) blocks.push(imageBlock) 77 + } 78 + } 79 + return blocks 80 + } 81 + 82 + function extractAssistantContentBlocks( 83 + msg: OpenAI.Chat.ChatCompletionAssistantMessageParam, 84 + ): AnthropicContentBlock[] { 85 + const blocks: AnthropicContentBlock[] = [] 86 + 87 + if (typeof msg.content === "string" && msg.content) { 88 + blocks.push({ type: "text" as const, text: msg.content }) 89 + } else if (Array.isArray(msg.content)) { 90 + for (const part of msg.content) { 91 + if (part.type === "text") { 92 + blocks.push({ type: "text" as const, text: part.text }) 93 + } 94 + } 95 + } 96 + 97 + if (msg.tool_calls) { 98 + for (const tc of msg.tool_calls) { 99 + if (tc.type === "function") { 100 + let input: Record<string, unknown> 101 + try { 102 + input = JSON.parse(tc.function.arguments) 103 + } catch { 104 + input = {} 105 + } 106 + blocks.push({ 107 + type: "tool_use" as const, 108 + id: tc.id, 109 + name: tc.function.name, 110 + input, 111 + }) 112 + } 113 + } 114 + } 115 + 116 + return blocks 117 + } 118 + 119 + function toAnthropicMessages( 120 + messages: OpenAI.Chat.ChatCompletionMessageParam[], 121 + ): { system?: string; messages: AnthropicMessageParam[] } { 122 + const systemParts: string[] = [] 123 + const out: AnthropicMessageParam[] = [] 124 + 125 + for (const msg of messages) { 126 + if (msg.role === "system") { 127 + if (typeof msg.content === "string") { 128 + systemParts.push(msg.content) 129 + } else if (Array.isArray(msg.content)) { 130 + for (const part of msg.content) { 131 + if (part.type === "text") systemParts.push(part.text) 132 + } 133 + } 134 + continue 135 + } 136 + 137 + if (msg.role === "user") { 138 + const blocks = extractUserContentBlocks(msg) 139 + const last = out[out.length - 1] 140 + if (last && last.role === "user") { 141 + const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }] 142 + last.content = [...existing, ...blocks] 143 + } else { 144 + out.push({ role: "user", content: blocks }) 145 + } 146 + continue 147 + } 148 + 149 + if (msg.role === "assistant") { 150 + const blocks = extractAssistantContentBlocks(msg as OpenAI.Chat.ChatCompletionAssistantMessageParam) 151 + const last = out[out.length - 1] 152 + if (last && last.role === "assistant") { 153 + const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }] 154 + last.content = [...existing, ...blocks] 155 + } else { 156 + out.push({ role: "assistant", content: blocks }) 157 + } 158 + continue 159 + } 160 + 161 + if (msg.role === "tool") { 162 + const toolMsg = msg as OpenAI.Chat.ChatCompletionToolMessageParam 163 + const block: Anthropic.ToolResultBlockParam = { 164 + type: "tool_result", 165 + tool_use_id: toolMsg.tool_call_id, 166 + content: typeof toolMsg.content === "string" ? toolMsg.content : JSON.stringify(toolMsg.content), 167 + } 168 + const last = out[out.length - 1] 169 + if (last && last.role === "user") { 170 + const existing = Array.isArray(last.content) ? last.content : [{ type: "text" as const, text: last.content }] 171 + last.content = [...existing, block] 172 + } else { 173 + out.push({ role: "user", content: [block] }) 174 + } 175 + continue 176 + } 177 + } 178 + 179 + return { 180 + system: systemParts.join("\n\n") || undefined, 181 + messages: out, 182 + } 183 + } 184 + 185 + // ── tool conversion ───────────────────────────────────────────────────── 186 + 187 + function toAnthropicTools(tools: OpenAI.Chat.ChatCompletionTool[]): Anthropic.Tool[] { 188 + return tools 189 + .filter((tool): tool is OpenAI.Chat.ChatCompletionTool & { type: "function"; function: { name: string; description?: string; parameters?: unknown } } => tool.type === "function") 190 + .map((tool) => ({ 191 + name: tool.function.name, 192 + description: tool.function.description ?? "", 193 + input_schema: (tool.function.parameters ?? { type: "object", properties: {} }) as Anthropic.Tool.InputSchema, 194 + })) 195 + } 196 + 197 + function toAnthropicToolChoice( 198 + toolChoice: "required" | "auto" | "none", 199 + ): Anthropic.ToolChoice | undefined { 200 + if (toolChoice === "required") return { type: "any" } 201 + if (toolChoice === "auto") return { type: "auto" } 202 + return undefined 203 + } 204 + 205 + // ── streaming conversion (Anthropic SSE → OpenAI chunks) ──────────────── 206 + 207 + function makeChunk(partial: Omit<OpenAI.Chat.ChatCompletionChunk.Choice.Delta, "role" | "content" | "tool_calls"> & { 208 + role?: "assistant" 209 + content?: string | null 210 + tool_calls?: OpenAI.Chat.ChatCompletionChunk.Choice.Delta.ToolCall[] 211 + }): OpenAI.Chat.ChatCompletionChunk { 212 + return { 213 + id: "anthropic-chunk", 214 + object: "chat.completion.chunk", 215 + created: Math.floor(Date.now() / 1000), 216 + model: "anthropic", 217 + choices: [{ delta: partial, finish_reason: null, index: 0 }], 218 + } 219 + } 220 + 221 + async function* anthropicStreamToOpenAI( 222 + stream: AsyncIterable<Anthropic.Messages.RawMessageStreamEvent>, 223 + ): AsyncGenerator<OpenAI.Chat.ChatCompletionChunk> { 224 + const toolBuffers = new Map<number, { id: string; name: string; arguments: string }>() 225 + let usage: { prompt_tokens: number; completion_tokens: number } | undefined 226 + 227 + for await (const event of stream) { 228 + switch (event.type) { 229 + case "message_start": { 230 + const inputTokens = event.message.usage?.input_tokens 231 + if (inputTokens != null) { 232 + usage = { prompt_tokens: inputTokens, completion_tokens: 0 } 233 + } 234 + yield makeChunk({ role: "assistant" }) 235 + break 236 + } 237 + 238 + case "content_block_start": { 239 + const index = event.index 240 + const block = event.content_block 241 + if (block.type === "tool_use") { 242 + toolBuffers.set(index, { id: block.id, name: block.name, arguments: "" }) 243 + yield makeChunk({ 244 + tool_calls: [ 245 + { 246 + index, 247 + id: block.id, 248 + type: "function", 249 + function: { name: block.name, arguments: "" }, 250 + }, 251 + ], 252 + }) 253 + } 254 + break 255 + } 256 + 257 + case "content_block_delta": { 258 + const index = event.index 259 + const delta = event.delta 260 + if (delta.type === "text_delta") { 261 + yield makeChunk({ content: delta.text }) 262 + } else if (delta.type === "input_json_delta") { 263 + const tool = toolBuffers.get(index) 264 + if (tool) { 265 + tool.arguments += delta.partial_json 266 + yield makeChunk({ 267 + tool_calls: [ 268 + { 269 + index, 270 + function: { arguments: delta.partial_json }, 271 + }, 272 + ], 273 + }) 274 + } 275 + } 276 + break 277 + } 278 + 279 + case "message_delta": { 280 + const outputTokens = event.usage?.output_tokens 281 + if (outputTokens != null && usage) { 282 + usage.completion_tokens = outputTokens 283 + } 284 + break 285 + } 286 + 287 + case "message_stop": 288 + break 289 + 290 + default: 291 + break 292 + } 293 + } 294 + 295 + if (usage) { 296 + yield { 297 + id: "anthropic-usage", 298 + object: "chat.completion.chunk", 299 + created: Math.floor(Date.now() / 1000), 300 + model: "anthropic", 301 + choices: [], 302 + usage: { 303 + prompt_tokens: usage.prompt_tokens, 304 + completion_tokens: usage.completion_tokens, 305 + total_tokens: usage.prompt_tokens + usage.completion_tokens, 306 + }, 307 + } 308 + } 309 + } 310 + 311 + // ── stream consumer ───────────────────────────────────────────────────── 312 + 313 + async function consumeAnthropicStream( 314 + stream: AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, 315 + ): Promise<CompletionTurnResult> { 316 + const startedAt = Date.now() 317 + const contentParts: string[] = [] 318 + const streamedToolCalls = new Map<number, ToolCallAssembly>() 319 + let usage: OpenAI.Completions.CompletionUsage | undefined 320 + let emittedText = false 321 + let emittedThinking = false 322 + 323 + for await (const chunk of stream) { 324 + if (chunk.usage) usage = chunk.usage 325 + 326 + const choice = chunk.choices[0] 327 + if (!choice) continue 328 + 329 + const delta = choice.delta as OpenAI.Chat.ChatCompletionChunk.Choice.Delta & { 330 + reasoning_content?: string 331 + } 332 + 333 + if (typeof delta.content === "string" && delta.content.length > 0) { 334 + contentParts.push(delta.content) 335 + emit({ type: "text", text: delta.content }) 336 + emittedText = true 337 + } 338 + 339 + if (!Array.isArray(delta.tool_calls)) continue 340 + 341 + for (const partial of delta.tool_calls) { 342 + const index = partial.index ?? 0 343 + const existing = streamedToolCalls.get(index) ?? { 344 + id: partial.id ?? `call_${index}`, 345 + type: "function" as const, 346 + function: { name: "", arguments: "" }, 347 + } 348 + 349 + if (partial.id) existing.id = partial.id 350 + if (partial.type === "function") existing.type = "function" 351 + if (partial.function?.name) existing.function.name += partial.function.name 352 + if (partial.function?.arguments) existing.function.arguments += partial.function.arguments 353 + streamedToolCalls.set(index, existing) 354 + } 355 + } 356 + 357 + const finalToolCalls = 358 + streamedToolCalls.size > 0 359 + ? [...streamedToolCalls.entries()] 360 + .sort((a, b) => a[0] - b[0]) 361 + .map(([, toolCall]) => toolCall) 362 + : [] 363 + 364 + const message: OpenAI.Chat.ChatCompletionMessage = { 365 + role: "assistant", 366 + content: contentParts.length > 0 ? contentParts.join("") : null, 367 + refusal: null, 368 + ...(finalToolCalls.length > 0 ? { tool_calls: finalToolCalls } : {}), 369 + } 370 + 371 + const elapsedMs = Math.max(0, Date.now() - startedAt) 372 + const tokensPerSecond = 373 + usage && elapsedMs > 0 ? usage.completion_tokens / (elapsedMs / 1000) : undefined 374 + 375 + return { 376 + message, 377 + usage, 378 + emittedText, 379 + emittedThinking, 380 + bufferedThinking: "", 381 + elapsedMs, 382 + tokensPerSecond, 383 + } 384 + } 385 + 386 + // ── public API ────────────────────────────────────────────────────────── 387 + 388 + export async function createAnthropicCompletion( 389 + request: CompletionRequest, 390 + ): Promise<CompletionTurnResult> { 391 + const { system, messages } = toAnthropicMessages(request.messages) 392 + 393 + const body: Anthropic.MessageCreateParams = { 394 + model: request.model, 395 + max_tokens: ANTHROPIC_MAX_TOKENS, 396 + messages, 397 + ...(system ? { system } : {}), 398 + } 399 + 400 + if (request.tools.length > 0 && request.tool_choice !== "none") { 401 + body.tools = toAnthropicTools(request.tools) 402 + const tc = toAnthropicToolChoice(request.tool_choice) 403 + if (tc) body.tool_choice = tc 404 + } 405 + 406 + const promptMetricId = recordMetric({ type: "prompt", messages: request.messages }) 407 + 408 + try { 409 + const stream = await client.messages.create({ ...body, stream: true }) 410 + const convertedStream = anthropicStreamToOpenAI(stream) 411 + const result = await consumeAnthropicStream(convertedStream) 412 + 413 + recordMetric({ 414 + type: "prompt_response", 415 + promptMetricId: promptMetricId ?? undefined, 416 + model: request.model, 417 + toolChoice: request.tool_choice, 418 + messages: request.messages, 419 + response: result.message, 420 + usage: result.usage, 421 + }) 422 + 423 + return result 424 + } catch (err) { 425 + throw wrapAnthropicError(err) 426 + } 427 + }
+12
src/runner/loop-completion.ts
··· 10 10 FALLBACK_BASE, 11 11 FALLBACK_MODEL, 12 12 FALLBACK_TOOL_CHOICE, 13 + ANTHROPIC_MODEL, 13 14 MODEL, 14 15 PRIMARY_TOOL_CHOICE, 15 16 SUMMARY_MODEL, 16 17 TOOLS, 18 + USE_ANTHROPIC, 17 19 USE_FALLBACK, 18 20 apiErrorDetails, 19 21 client, ··· 33 35 summaryClient, 34 36 summarizeConversationViaLLM, 35 37 } from "./util" 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 + if (USE_ANTHROPIC) { 526 + return createAnthropicCompletion({ 527 + model: ANTHROPIC_MODEL, 528 + messages, 529 + tools: TOOLS, 530 + tool_choice: PRIMARY_TOOL_CHOICE, 531 + }) 532 + } 533 + 522 534 const request: CompletionRequest = { 523 535 model: MODEL, 524 536 messages,
+24 -5
src/runner/util.ts
··· 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 + 65 + export const USE_ANTHROPIC = parseBooleanEnv(process.env.USE_ANTHROPIC, false) 66 + export const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1" 67 + export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY ?? "" 68 + export const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? "" 69 + export const ANTHROPIC_MAX_TOKENS = Math.max(1, Number.parseInt(process.env.ANTHROPIC_MAX_TOKENS ?? "8192", 10)) || 8192 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 - if (!USE_FALLBACK && !MODEL) { 100 - throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local).") 106 + if (!USE_FALLBACK && !USE_ANTHROPIC && !MODEL) { 107 + throw new Error("MODEL is required unless fallback is forced (NIRI_ENV=local) or Anthropic is used (USE_ANTHROPIC=true).") 108 + } 109 + 110 + if (!USE_FALLBACK && !USE_ANTHROPIC && !process.env.OPENAI_API_KEY) { 111 + throw new Error("OPENAI_API_KEY is required unless fallback is forced (NIRI_ENV=local) or Anthropic is used (USE_ANTHROPIC=true).") 112 + } 113 + 114 + if (USE_ANTHROPIC && !ANTHROPIC_API_KEY) { 115 + throw new Error("ANTHROPIC_API_KEY is required when USE_ANTHROPIC=true.") 101 116 } 102 117 103 - if (!USE_FALLBACK && !process.env.OPENAI_API_KEY) { 104 - throw new Error("OPENAI_API_KEY is required unless fallback is forced (NIRI_ENV=local).") 118 + if (USE_ANTHROPIC && !ANTHROPIC_MODEL) { 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 - console.log(`[config] primary=${MODEL} @ ${API_BASE}`) 157 + if (USE_ANTHROPIC) { 158 + console.log(`[config] primary=${ANTHROPIC_MODEL} @ ${ANTHROPIC_BASE_URL} (anthropic)`) 159 + } else { 160 + console.log(`[config] primary=${MODEL} @ ${API_BASE}`) 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}`)