spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * anthropic.ts3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Native Anthropic Messages API client (/v1/messages) — NOT OpenAI-compatible.9 * Ported 1:1 from AnthropicClient.swift. Auth: x-api-key + anthropic-version,10 * plus the browser CORS opt-in header (see docs/CORS-MATRIX.md). System prompt11 * is a top-level param, content is block-structured, max_tokens is mandatory,12 * streaming uses named SSE events.13 */1415import type { Citation, Message, Provider, TokenUsage } from '../types'16import { PROVIDER_META } from './registry'17import { joinURL, requestJSON, sseEvents } from './sse'18import {19 ProviderError,20 type ChatEvent,21 type ChatRequest,22 type CompletionResult,23 type ProviderClient,24} from './types'2526const API_VERSION = '2023-06-01'27const DEFAULT_MAX_TOKENS = 81922829// --- Wire types ---3031type WireBlock =32 | { type: 'text'; text: string }33 | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }3435interface WireRequestBody {36 model: string37 max_tokens: number38 messages: { role: string; content: WireBlock[] }[]39 system?: string40 stream?: boolean41 temperature?: number42 top_p?: number43 thinking?: { type: 'enabled'; budget_tokens: number } | { type: 'disabled' }44}4546interface WireStreamEvent {47 type?: string48 delta?: { type?: string; text?: string; thinking?: string; stop_reason?: string }49 usage?: { input_tokens?: number; output_tokens?: number }50 message?: { usage?: { input_tokens?: number; output_tokens?: number } }51 error?: { message?: string }52}5354interface WireResponse {55 content?: { type?: string; text?: string; thinking?: string }[]56 usage?: { input_tokens?: number; output_tokens?: number }57 stop_reason?: string58}5960export class AnthropicClient implements ProviderClient {61 readonly provider: Provider = 'anthropic'6263 private baseURL(request?: { baseURLOverride?: string }): string {64 const base = request?.baseURLOverride ?? PROVIDER_META.anthropic.defaultBaseURL65 if (!base) throw ProviderError.invalidResponse(this.provider, 'no base URL configured')66 return base67 }6869 private headers(apiKey: string): Record<string, string> {70 return {71 'x-api-key': apiKey,72 'anthropic-version': API_VERSION,73 // Anthropic's documented browser CORS opt-in. The name is Anthropic's74 // deliberate reminder that browser-resident keys are user-visible —75 // which is this app's transparent bring-your-own-key model.76 'anthropic-dangerous-direct-browser-access': 'true',77 'Content-Type': 'application/json',78 }79 }8081 private buildBody(request: ChatRequest): WireRequestBody {82 const messages: WireRequestBody['messages'] = []83 for (const message of request.messages) {84 if (message.role === 'system') continue85 messages.push(wireMessage(message, request.model.capabilities.vision))86 }87 const params = request.parameters88 const body: WireRequestBody = {89 model: request.model.id,90 max_tokens: params.maxTokens ?? request.model.maxOutputTokens ?? DEFAULT_MAX_TOKENS,91 messages,92 }93 if (request.systemPrompt && request.systemPrompt !== '') body.system = request.systemPrompt94 if (request.stream) body.stream = true95 // Claude 4.7+ removed temperature/top_p; ParameterSupport encodes that per model.96 const support = request.model.parameterSupport97 if (support.temperature && params.temperature !== undefined) {98 body.temperature = params.temperature99 }100 if (support.topP && params.topP !== undefined) body.top_p = params.topP101 if (support.thinkingToggle && params.thinkingEnabled !== undefined) {102 body.thinking = params.thinkingEnabled103 ? { type: 'enabled', budget_tokens: 8000 }104 : { type: 'disabled' }105 }106 return body107 }108109 async *streamChat(110 request: ChatRequest,111 apiKey: string,112 signal?: AbortSignal113 ): AsyncGenerator<ChatEvent> {114 const url = joinURL(this.baseURL(request), 'messages')115 const body = this.buildBody({ ...request, stream: true })116117 const usage: TokenUsage = { inputTokens: 0, outputTokens: 0 }118 let stopReason: string | null = null119120 for await (const sse of sseEvents(121 url,122 { headers: this.headers(apiKey), body: JSON.stringify(body) },123 this.provider,124 signal125 )) {126 let event: WireStreamEvent127 try {128 event = JSON.parse(sse.data) as WireStreamEvent129 } catch {130 continue131 }132 const type = sse.event ?? event.type ?? ''133 switch (type) {134 case 'message_start':135 usage.inputTokens = event.message?.usage?.input_tokens ?? 0136 break137 case 'content_block_delta':138 if (event.delta?.text) yield { type: 'textDelta', text: event.delta.text }139 if (event.delta?.thinking) yield { type: 'reasoningDelta', text: event.delta.thinking }140 break141 case 'message_delta':142 usage.outputTokens = event.usage?.output_tokens ?? usage.outputTokens143 if (event.delta?.stop_reason) stopReason = event.delta.stop_reason144 break145 case 'error':146 throw ProviderError.serverError(this.provider, 200, event.error?.message ?? null)147 case 'message_stop':148 break149 default:150 break // ping, content_block_start/stop, unknown future events151 }152 }153 yield { type: 'usage', usage }154 yield { type: 'finished', reason: stopReason }155 }156157 async complete(158 request: ChatRequest,159 apiKey: string,160 signal?: AbortSignal161 ): Promise<CompletionResult> {162 const url = joinURL(this.baseURL(request), 'messages')163 const body = this.buildBody({ ...request, stream: false })164 const responseText = await requestJSON(165 url,166 { method: 'POST', headers: this.headers(apiKey), body: JSON.stringify(body) },167 this.provider,168 signal169 )170 let response: WireResponse171 try {172 response = JSON.parse(responseText) as WireResponse173 } catch {174 throw ProviderError.invalidResponse(this.provider, 'undecodable messages response')175 }176 const blocks = response.content ?? []177 const text = blocks178 .filter((b) => b.type === 'text')179 .map((b) => b.text ?? '')180 .join('')181 const thinking = blocks182 .filter((b) => b.type === 'thinking')183 .map((b) => b.thinking ?? '')184 .join('')185 const result: CompletionResult = { text }186 if (thinking !== '') result.reasoning = thinking187 if (response.usage) {188 result.usage = {189 inputTokens: response.usage.input_tokens ?? 0,190 outputTokens: response.usage.output_tokens ?? 0,191 }192 }193 return result194 }195196 async listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]> {197 const url =198 joinURL(199 this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined),200 'models'201 ) + '?limit=100'202 const responseText = await requestJSON(203 url,204 {205 method: 'GET',206 headers: {207 'x-api-key': apiKey,208 'anthropic-version': API_VERSION,209 'anthropic-dangerous-direct-browser-access': 'true',210 },211 },212 this.provider213 )214 let parsed: { data?: { id?: unknown }[] }215 try {216 parsed = JSON.parse(responseText) as { data?: { id?: unknown }[] }217 } catch {218 throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')219 }220 if (!Array.isArray(parsed.data)) {221 throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')222 }223 return parsed.data.map((e) => e.id).filter((id): id is string => typeof id === 'string')224 }225}226227// Citation type is unused by Anthropic but kept for interface parity.228export type { Citation }229230function wireMessage(message: Message, vision: boolean): { role: string; content: WireBlock[] } {231 const role = message.role === 'assistant' ? 'assistant' : 'user'232 let text = message.text233 for (const attachment of message.attachments ?? []) {234 if (attachment.kind === 'textFile') {235 text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\``236 }237 }238 const blocks: WireBlock[] = []239 if (vision && message.role === 'user') {240 for (const image of message.attachments ?? []) {241 if (image.kind === 'image') {242 blocks.push({243 type: 'image',244 source: { type: 'base64', media_type: image.mimeType, data: image.data },245 })246 }247 }248 }249 blocks.push({ type: 'text', text: text === '' ? ' ' : text })250 return { role, content: blocks }251}252