/* * anthropic.ts * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible. * Ported 1:1 from AnthropicClient.swift. Auth: x-api-key + anthropic-version, * plus the browser CORS opt-in header (see docs/CORS-MATRIX.md). System prompt * is a top-level param, content is block-structured, max_tokens is mandatory, * streaming uses named SSE events. */ import type { Citation, Message, Provider, TokenUsage } from '../types' import { PROVIDER_META } from './registry' import { joinURL, requestJSON, sseEvents } from './sse' import { ProviderError, type ChatEvent, type ChatRequest, type CompletionResult, type ProviderClient, } from './types' const API_VERSION = '2023-06-01' const DEFAULT_MAX_TOKENS = 8192 // --- Wire types --- type WireBlock = | { type: 'text'; text: string } | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } interface WireRequestBody { model: string max_tokens: number messages: { role: string; content: WireBlock[] }[] system?: string stream?: boolean temperature?: number top_p?: number thinking?: { type: 'enabled'; budget_tokens: number } | { type: 'disabled' } } interface WireStreamEvent { type?: string delta?: { type?: string; text?: string; thinking?: string; stop_reason?: string } usage?: { input_tokens?: number; output_tokens?: number } message?: { usage?: { input_tokens?: number; output_tokens?: number } } error?: { message?: string } } interface WireResponse { content?: { type?: string; text?: string; thinking?: string }[] usage?: { input_tokens?: number; output_tokens?: number } stop_reason?: string } export class AnthropicClient implements ProviderClient { readonly provider: Provider = 'anthropic' private baseURL(request?: { baseURLOverride?: string }): string { const base = request?.baseURLOverride ?? PROVIDER_META.anthropic.defaultBaseURL if (!base) throw ProviderError.invalidResponse(this.provider, 'no base URL configured') return base } private headers(apiKey: string): Record { return { 'x-api-key': apiKey, 'anthropic-version': API_VERSION, // Anthropic's documented browser CORS opt-in. The name is Anthropic's // deliberate reminder that browser-resident keys are user-visible — // which is this app's transparent bring-your-own-key model. 'anthropic-dangerous-direct-browser-access': 'true', 'Content-Type': 'application/json', } } private buildBody(request: ChatRequest): WireRequestBody { const messages: WireRequestBody['messages'] = [] for (const message of request.messages) { if (message.role === 'system') continue messages.push(wireMessage(message, request.model.capabilities.vision)) } const params = request.parameters const body: WireRequestBody = { model: request.model.id, max_tokens: params.maxTokens ?? request.model.maxOutputTokens ?? DEFAULT_MAX_TOKENS, messages, } if (request.systemPrompt && request.systemPrompt !== '') body.system = request.systemPrompt if (request.stream) body.stream = true // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model. const support = request.model.parameterSupport if (support.temperature && params.temperature !== undefined) { body.temperature = params.temperature } if (support.topP && params.topP !== undefined) body.top_p = params.topP if (support.thinkingToggle && params.thinkingEnabled !== undefined) { body.thinking = params.thinkingEnabled ? { type: 'enabled', budget_tokens: 8000 } : { type: 'disabled' } } return body } async *streamChat( request: ChatRequest, apiKey: string, signal?: AbortSignal ): AsyncGenerator { const url = joinURL(this.baseURL(request), 'messages') const body = this.buildBody({ ...request, stream: true }) const usage: TokenUsage = { inputTokens: 0, outputTokens: 0 } let stopReason: string | null = null for await (const sse of sseEvents( url, { headers: this.headers(apiKey), body: JSON.stringify(body) }, this.provider, signal )) { let event: WireStreamEvent try { event = JSON.parse(sse.data) as WireStreamEvent } catch { continue } const type = sse.event ?? event.type ?? '' switch (type) { case 'message_start': usage.inputTokens = event.message?.usage?.input_tokens ?? 0 break case 'content_block_delta': if (event.delta?.text) yield { type: 'textDelta', text: event.delta.text } if (event.delta?.thinking) yield { type: 'reasoningDelta', text: event.delta.thinking } break case 'message_delta': usage.outputTokens = event.usage?.output_tokens ?? usage.outputTokens if (event.delta?.stop_reason) stopReason = event.delta.stop_reason break case 'error': throw ProviderError.serverError(this.provider, 200, event.error?.message ?? null) case 'message_stop': break default: break // ping, content_block_start/stop, unknown future events } } yield { type: 'usage', usage } yield { type: 'finished', reason: stopReason } } async complete( request: ChatRequest, apiKey: string, signal?: AbortSignal ): Promise { const url = joinURL(this.baseURL(request), 'messages') const body = this.buildBody({ ...request, stream: false }) const responseText = await requestJSON( url, { method: 'POST', headers: this.headers(apiKey), body: JSON.stringify(body) }, this.provider, signal ) let response: WireResponse try { response = JSON.parse(responseText) as WireResponse } catch { throw ProviderError.invalidResponse(this.provider, 'undecodable messages response') } const blocks = response.content ?? [] const text = blocks .filter((b) => b.type === 'text') .map((b) => b.text ?? '') .join('') const thinking = blocks .filter((b) => b.type === 'thinking') .map((b) => b.thinking ?? '') .join('') const result: CompletionResult = { text } if (thinking !== '') result.reasoning = thinking if (response.usage) { result.usage = { inputTokens: response.usage.input_tokens ?? 0, outputTokens: response.usage.output_tokens ?? 0, } } return result } async listModelIDs(apiKey: string, baseURLOverride?: string): Promise { const url = joinURL( this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined), 'models' ) + '?limit=100' const responseText = await requestJSON( url, { method: 'GET', headers: { 'x-api-key': apiKey, 'anthropic-version': API_VERSION, 'anthropic-dangerous-direct-browser-access': 'true', }, }, this.provider ) let parsed: { data?: { id?: unknown }[] } try { parsed = JSON.parse(responseText) as { data?: { id?: unknown }[] } } catch { throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape') } if (!Array.isArray(parsed.data)) { throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape') } return parsed.data.map((e) => e.id).filter((id): id is string => typeof id === 'string') } } // Citation type is unused by Anthropic but kept for interface parity. export type { Citation } function wireMessage(message: Message, vision: boolean): { role: string; content: WireBlock[] } { const role = message.role === 'assistant' ? 'assistant' : 'user' let text = message.text for (const attachment of message.attachments ?? []) { if (attachment.kind === 'textFile') { text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\`` } } const blocks: WireBlock[] = [] if (vision && message.role === 'user') { for (const image of message.attachments ?? []) { if (image.kind === 'image') { blocks.push({ type: 'image', source: { type: 'base64', media_type: image.mimeType, data: image.data }, }) } } } blocks.push({ type: 'text', text: text === '' ? ' ' : text }) return { role, content: blocks } }