SPB Git

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%
8.4 KB · 252 lines typescript
Raw Blame History
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