|
| 1 | +import Anthropic from "@anthropic-ai/sdk"; |
| 2 | +import type { RawMessageStreamEvent } from "@anthropic-ai/sdk/resources/messages.mjs"; |
| 3 | +import type { ProviderAdapter } from "./base"; |
| 4 | +import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; |
| 5 | +import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic"; |
| 6 | +import { parseDataUrl } from "./image"; |
| 7 | + |
| 8 | +function toContentPart(p: OcxContentPart): unknown { |
| 9 | + if (p.type === "image") { |
| 10 | + const data = parseDataUrl(p.imageUrl); |
| 11 | + return data |
| 12 | + ? { type: "image", source: { type: "base64", media_type: data.mediaType, data: data.base64 } } |
| 13 | + : { type: "image", source: { type: "url", url: p.imageUrl } }; |
| 14 | + } |
| 15 | + return { type: "text", text: p.text }; |
| 16 | +} |
| 17 | + |
| 18 | +const DEFAULT_MAX_TOKENS = 8192; |
| 19 | +const REASONING_MAX_TOKENS_CEILING = 32_000; |
| 20 | +const MIN_THINKING_BUDGET = 1024; |
| 21 | +const OUTPUT_HEADROOM = 8192; |
| 22 | +const OUTPUT_FLOOR = 4096; |
| 23 | + |
| 24 | +function reasoningBudget(effort: string): number { |
| 25 | + switch (effort) { |
| 26 | + case "minimal": return 1024; |
| 27 | + case "low": return 4096; |
| 28 | + case "high": return 16384; |
| 29 | + case "xhigh": return 24576; |
| 30 | + case "max": return 32000; |
| 31 | + case "medium": |
| 32 | + default: return 8192; |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +function usageFromSdk(usage: { output_tokens: number } | undefined): OcxUsage | undefined { |
| 37 | + if (!usage) return undefined; |
| 38 | + return { inputTokens: 0, outputTokens: usage.output_tokens }; |
| 39 | +} |
| 40 | + |
| 41 | +function buildMessages(parsed: OcxParsedRequest, isOAuth: boolean): { system: unknown; messages: unknown[] } { |
| 42 | + const systemText = parsed.context.systemPrompt?.join("\n\n") || undefined; |
| 43 | + const system = isOAuth |
| 44 | + ? [{ type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }, ...(systemText ? [{ type: "text", text: systemText }] : [])] |
| 45 | + : systemText; |
| 46 | + |
| 47 | + const messages: unknown[] = []; |
| 48 | + for (const msg of parsed.context.messages) { |
| 49 | + switch (msg.role) { |
| 50 | + case "user": |
| 51 | + case "developer": { |
| 52 | + const content = typeof msg.content === "string" |
| 53 | + ? msg.content |
| 54 | + : (msg.content as OcxContentPart[]).map(toContentPart); |
| 55 | + messages.push({ role: "user", content }); |
| 56 | + break; |
| 57 | + } |
| 58 | + case "assistant": { |
| 59 | + const aMsg = msg as OcxAssistantMessage; |
| 60 | + const content: unknown[] = []; |
| 61 | + for (const part of aMsg.content) { |
| 62 | + if (part.type === "text") content.push({ type: "text", text: (part as OcxTextContent).text }); |
| 63 | + else if (part.type === "thinking") { |
| 64 | + const t = part as OcxThinkingContent; |
| 65 | + content.push({ type: "thinking", thinking: t.thinking, ...(t.signature ? { signature: t.signature } : {}) }); |
| 66 | + } else if (part.type === "toolCall") { |
| 67 | + const tc = part as OcxToolCall; |
| 68 | + content.push({ type: "tool_use", id: tc.id, name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name, input: tc.arguments }); |
| 69 | + } |
| 70 | + } |
| 71 | + messages.push({ role: "assistant", content }); |
| 72 | + break; |
| 73 | + } |
| 74 | + case "toolResult": { |
| 75 | + const trContent = typeof msg.content === "string" |
| 76 | + ? msg.content |
| 77 | + : (msg.content as OcxContentPart[]).map(toContentPart); |
| 78 | + messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: msg.toolCallId, content: trContent }] }); |
| 79 | + break; |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + return { system, messages }; |
| 84 | +} |
| 85 | + |
| 86 | +function buildTools(parsed: OcxParsedRequest, isOAuth: boolean): unknown[] | undefined { |
| 87 | + if (!parsed.context.tools?.length) return undefined; |
| 88 | + return parsed.context.tools.map(t => ({ |
| 89 | + name: isOAuth ? applyClaudeToolPrefix(t.name) : t.name, |
| 90 | + description: t.description, |
| 91 | + input_schema: t.parameters, |
| 92 | + })); |
| 93 | +} |
| 94 | + |
| 95 | +export function createAnthropicSdkAdapter(provider: OcxProviderConfig): ProviderAdapter { |
| 96 | + const isOAuth = provider.authMode === "oauth"; |
| 97 | + |
| 98 | + return { |
| 99 | + name: "anthropic-sdk", |
| 100 | + |
| 101 | + buildRequest(parsed: OcxParsedRequest) { |
| 102 | + const { system, messages } = buildMessages(parsed, isOAuth); |
| 103 | + const tools = buildTools(parsed, isOAuth); |
| 104 | + const body: Record<string, unknown> = { |
| 105 | + model: parsed.modelId, messages, stream: parsed.stream, |
| 106 | + max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, |
| 107 | + }; |
| 108 | + if (system) body.system = system; |
| 109 | + if (tools) body.tools = tools; |
| 110 | + if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature; |
| 111 | + if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP; |
| 112 | + if (parsed.options.stopSequences) body.stop_sequences = parsed.options.stopSequences; |
| 113 | + if (parsed.options.reasoning) { |
| 114 | + const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; |
| 115 | + const wantBudget = reasoningBudget(parsed.options.reasoning); |
| 116 | + const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); |
| 117 | + const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); |
| 118 | + body.max_tokens = maxTokens; |
| 119 | + body.thinking = { type: "enabled", budget_tokens: budget }; |
| 120 | + delete body.temperature; |
| 121 | + delete body.top_p; |
| 122 | + } |
| 123 | + if (parsed.options.toolChoice) { |
| 124 | + const tc = parsed.options.toolChoice; |
| 125 | + if (tc === "auto") body.tool_choice = { type: "auto" }; |
| 126 | + else if (tc === "none") body.tool_choice = { type: "none" }; |
| 127 | + else if (tc === "required") body.tool_choice = { type: "any" }; |
| 128 | + else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name }; |
| 129 | + } |
| 130 | + const url = `${provider.baseUrl}/v1/messages`; |
| 131 | + const headers: Record<string, string> = { "Content-Type": "application/json", "anthropic-version": "2023-06-01" }; |
| 132 | + if (isOAuth) { |
| 133 | + if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`; |
| 134 | + headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; |
| 135 | + } else if (provider.apiKey) { |
| 136 | + headers["x-api-key"] = provider.apiKey; |
| 137 | + } |
| 138 | + if (provider.headers) Object.assign(headers, provider.headers); |
| 139 | + return { url, method: "POST", headers, body: JSON.stringify(body) }; |
| 140 | + }, |
| 141 | + |
| 142 | + async *parseStream(): AsyncGenerator<AdapterEvent> { |
| 143 | + yield { type: "error", message: "anthropic-sdk adapter uses executeStream; parseStream should not be called directly" }; |
| 144 | + }, |
| 145 | + |
| 146 | + async *executeStream(parsed: OcxParsedRequest, signal?: AbortSignal): AsyncGenerator<AdapterEvent> { |
| 147 | + const { system, messages } = buildMessages(parsed, isOAuth); |
| 148 | + const tools = buildTools(parsed, isOAuth); |
| 149 | + |
| 150 | + const params: Record<string, unknown> = { |
| 151 | + model: parsed.modelId, messages, stream: true, |
| 152 | + max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, |
| 153 | + }; |
| 154 | + if (system) params.system = system; |
| 155 | + if (tools) params.tools = tools; |
| 156 | + if (parsed.options.temperature !== undefined) params.temperature = parsed.options.temperature; |
| 157 | + if (parsed.options.topP !== undefined) params.top_p = parsed.options.topP; |
| 158 | + if (parsed.options.stopSequences) params.stop_sequences = parsed.options.stopSequences; |
| 159 | + |
| 160 | + if (parsed.options.reasoning) { |
| 161 | + const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; |
| 162 | + const wantBudget = reasoningBudget(parsed.options.reasoning); |
| 163 | + const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM)); |
| 164 | + const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR)); |
| 165 | + params.max_tokens = maxTokens; |
| 166 | + params.thinking = { type: "enabled", budget_tokens: budget }; |
| 167 | + delete params.temperature; |
| 168 | + delete params.top_p; |
| 169 | + } |
| 170 | + |
| 171 | + if (parsed.options.toolChoice) { |
| 172 | + const tc = parsed.options.toolChoice; |
| 173 | + if (tc === "auto") params.tool_choice = { type: "auto" }; |
| 174 | + else if (tc === "none") params.tool_choice = { type: "none" }; |
| 175 | + else if (tc === "required") params.tool_choice = { type: "any" }; |
| 176 | + else if (typeof tc === "object" && "name" in tc) params.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name }; |
| 177 | + } |
| 178 | + |
| 179 | + const sdkHeaders: Record<string, string> = {}; |
| 180 | + if (isOAuth) sdkHeaders["anthropic-beta"] = ANTHROPIC_OAUTH_BETA; |
| 181 | + if (provider.headers) Object.assign(sdkHeaders, provider.headers); |
| 182 | + |
| 183 | + const client = new Anthropic({ |
| 184 | + apiKey: provider.apiKey ?? "", |
| 185 | + baseURL: `${provider.baseUrl}/v1`, |
| 186 | + maxRetries: 2, |
| 187 | + defaultHeaders: sdkHeaders, |
| 188 | + ...(isOAuth ? { authToken: provider.apiKey } : {}), |
| 189 | + }); |
| 190 | + |
| 191 | + let stream: AsyncIterable<RawMessageStreamEvent>; |
| 192 | + try { |
| 193 | + stream = await client.messages.create( |
| 194 | + params as unknown as Parameters<typeof client.messages.create>[0], |
| 195 | + { signal }, |
| 196 | + ) as AsyncIterable<RawMessageStreamEvent>; |
| 197 | + } catch (err) { |
| 198 | + yield { type: "error", message: err instanceof Error ? err.message : String(err) }; |
| 199 | + return; |
| 200 | + } |
| 201 | + |
| 202 | + let inToolUse = false; |
| 203 | + for await (const event of stream) { |
| 204 | + switch (event.type) { |
| 205 | + case "content_block_start": |
| 206 | + if (event.content_block.type === "tool_use") { |
| 207 | + const name = isOAuth ? stripClaudeToolPrefix(event.content_block.name) : event.content_block.name; |
| 208 | + inToolUse = true; |
| 209 | + yield { type: "tool_call_start", id: event.content_block.id, name }; |
| 210 | + } |
| 211 | + break; |
| 212 | + case "content_block_delta": |
| 213 | + if (event.delta.type === "text_delta") yield { type: "text_delta", text: event.delta.text }; |
| 214 | + else if (event.delta.type === "thinking_delta") yield { type: "thinking_delta", thinking: event.delta.thinking }; |
| 215 | + else if (event.delta.type === "input_json_delta") yield { type: "tool_call_delta", arguments: event.delta.partial_json }; |
| 216 | + break; |
| 217 | + case "content_block_stop": |
| 218 | + if (inToolUse) { yield { type: "tool_call_end" }; inToolUse = false; } |
| 219 | + break; |
| 220 | + case "message_delta": |
| 221 | + yield { type: "done", usage: usageFromSdk(event.usage) }; |
| 222 | + break; |
| 223 | + default: |
| 224 | + break; |
| 225 | + } |
| 226 | + } |
| 227 | + }, |
| 228 | + }; |
| 229 | +} |
0 commit comments