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%
12.5 KB · 382 lines typescript
Raw Blame History
1/*2 *  openaiCompatible.ts3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  One client for every provider speaking the OpenAI /chat/completions schema:9 *  OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek,10 *  Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints.11 *  Ported 1:1 from the native OpenAICompatibleClient.swift — all provider12 *  quirks live HERE, nothing leaks above the providers/ layer.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'2526// --- Wire types (requests) ---2728type WirePart =29  | { type: 'text'; text: string }30  | { type: 'image_url'; image_url: { url: string } }3132interface WireMessage {33  role: string34  content: string | WirePart[]35}3637interface WireRequestBody {38  model: string39  messages: WireMessage[]40  stream?: boolean41  stream_options?: { include_usage: boolean }42  temperature?: number43  top_p?: number44  max_tokens?: number45  max_completion_tokens?: number46  frequency_penalty?: number47  presence_penalty?: number48  reasoning_effort?: string49  enable_thinking?: boolean50}5152// --- Wire types (responses) ---5354/**55 * Mistral reasoning models return `delta.content` as an ARRAY of chunks:56 * {"type":"text","text":…} or {"type":"thinking","thinking":[{"type":"text","text":…}]}.57 */58interface WireContentChunk {59  type?: string60  text?: string61  thinking?: { text?: string }[]62}6364interface WireDelta {65  content?: string | WireContentChunk[]66  reasoning_content?: string67  reasoning?: string68}6970interface WireChoice {71  delta?: WireDelta72  message?: WireDelta73  /** Together streams some models completions-style: token text in choices[].text. */74  text?: string75  finish_reason?: string | null76}7778interface WireUsage {79  prompt_tokens?: number80  completion_tokens?: number81  completion_tokens_details?: { reasoning_tokens?: number }82}8384interface WireChunk {85  choices?: WireChoice[]86  usage?: WireUsage | null87  citations?: string[]88  search_results?: { title?: string; url?: string }[]89}9091/** Splits a WireDelta's content into text + reasoning (Mistral array quirk). */92function splitDelta(delta: WireDelta): { text: string; reasoning: string } {93  let reasoning = delta.reasoning_content ?? delta.reasoning ?? ''94  let text = ''95  if (typeof delta.content === 'string') {96    text = delta.content97  } else if (Array.isArray(delta.content)) {98    const textParts: string[] = []99    const thinkingParts: string[] = []100    for (const chunk of delta.content) {101      const flattened = chunk.text ?? (chunk.thinking ?? []).map((p) => p.text ?? '').join('')102      if (chunk.type === 'thinking') thinkingParts.push(flattened)103      else textParts.push(flattened)104    }105    text = textParts.join('')106    if (reasoning === '') reasoning = thinkingParts.join('')107  }108  return { text, reasoning }109}110111function toUsage(wire: WireUsage): TokenUsage {112  const reasoningTokens = wire.completion_tokens_details?.reasoning_tokens113  return {114    inputTokens: wire.prompt_tokens ?? 0,115    outputTokens: wire.completion_tokens ?? 0,116    ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),117  }118}119120/**121 * Perplexity: `citations` is an array of URL strings; `search_results` adds122 * titles. Merge both into numbered citations.123 */124function citationsFrom(chunk: WireChunk): Citation[] | null {125  const urls = chunk.citations126  if (!urls || urls.length === 0) return null127  const titles = chunk.search_results ?? []128  return urls.map((url, index) => {129    const title = titles[index]?.title130    return { index: index + 1, url, ...(title ? { title } : {}) }131  })132}133134export class OpenAICompatibleClient implements ProviderClient {135  readonly provider: Provider136137  constructor(provider: Provider) {138    this.provider = provider139  }140141  private baseURL(request?: { baseURLOverride?: string; model?: { customBaseURL?: string } }): string {142    const override = request?.baseURLOverride ?? request?.model?.customBaseURL143    const base = override ?? PROVIDER_META[this.provider].defaultBaseURL144    if (!base) {145      throw ProviderError.invalidResponse(this.provider, 'no base URL configured')146    }147    return base148  }149150  private headers(apiKey: string): Record<string, string> {151    return {152      Authorization: `Bearer ${apiKey}`,153      'Content-Type': 'application/json',154    }155  }156157  private buildBody(request: ChatRequest): WireRequestBody {158    const messages: WireMessage[] = []159    if (request.systemPrompt && request.systemPrompt !== '') {160      messages.push({ role: 'system', content: request.systemPrompt })161    }162    for (const message of request.messages) {163      if (message.role === 'system') continue164      messages.push(wireMessage(message, request.model.capabilities.vision))165    }166167    const support = request.model.parameterSupport168    const params = request.parameters169    const body: WireRequestBody = { model: request.model.id, messages }170171    if (request.stream) {172      body.stream = true173      if (PROVIDER_META[this.provider].wantsStreamOptions) {174        body.stream_options = { include_usage: true }175      }176    }177    if (support.temperature && params.temperature !== undefined) {178      body.temperature = params.temperature179    }180    if (support.topP && params.topP !== undefined) body.top_p = params.topP181    if (params.maxTokens !== undefined) {182      if (support.usesMaxCompletionTokens) body.max_completion_tokens = params.maxTokens183      else body.max_tokens = params.maxTokens184    }185    if (support.frequencyPenalty && params.frequencyPenalty !== undefined) {186      body.frequency_penalty = params.frequencyPenalty187    }188    if (support.presencePenalty && params.presencePenalty !== undefined) {189      body.presence_penalty = params.presencePenalty190    }191    if (support.reasoningEffort && params.reasoningEffort !== undefined) {192      // Mistral only accepts "high"/"none": map medium→high, low→none.193      if (this.provider === 'mistral') {194        body.reasoning_effort = params.reasoningEffort === 'low' ? 'none' : 'high'195      } else {196        body.reasoning_effort = params.reasoningEffort197      }198    }199    if (support.thinkingToggle && this.provider === 'qwen') {200      // DashScope: enable_thinking is only legal on streaming requests.201      if (request.stream && params.thinkingEnabled !== undefined) {202        body.enable_thinking = params.thinkingEnabled203      }204    }205    return body206  }207208  async *streamChat(209    request: ChatRequest,210    apiKey: string,211    signal?: AbortSignal212  ): AsyncGenerator<ChatEvent> {213    const url = joinURL(this.baseURL(request), 'chat/completions')214    const body = this.buildBody({ ...request, stream: true })215216    let citationsSent = false217    let finishReason: string | null = null218219    for await (const event of sseEvents(220      url,221      { headers: this.headers(apiKey), body: JSON.stringify(body) },222      this.provider,223      signal224    )) {225      if (event.data === '[DONE]') break226      let chunk: WireChunk227      try {228        chunk = JSON.parse(event.data) as WireChunk229      } catch {230        continue // tolerate unknown/malformed keep-alive chunks231      }232      const choice = chunk.choices?.[0]233      if (choice) {234        const { text, reasoning } = choice.delta235          ? splitDelta(choice.delta)236          : { text: '', reasoning: '' }237        if (reasoning !== '') yield { type: 'reasoningDelta', text: reasoning }238        const deltaText = text !== '' ? text : (choice.text ?? '')239        if (deltaText !== '') yield { type: 'textDelta', text: deltaText }240        if (choice.finish_reason != null) finishReason = choice.finish_reason241      }242      if (!citationsSent) {243        const citations = citationsFrom(chunk)244        if (citations && citations.length > 0) {245          citationsSent = true246          yield { type: 'citations', citations }247        }248      }249      if (chunk.usage) yield { type: 'usage', usage: toUsage(chunk.usage) }250    }251    yield { type: 'finished', reason: finishReason }252  }253254  async complete(255    request: ChatRequest,256    apiKey: string,257    signal?: AbortSignal258  ): Promise<CompletionResult> {259    // Some models reject non-streaming calls — aggregate a stream instead.260    if (request.model.parameterSupport.requiresStreaming) {261      return this.completeViaStream(request, apiKey, signal)262    }263    const url = joinURL(this.baseURL(request), 'chat/completions')264    const body = this.buildBody({ ...request, stream: false })265    const responseText = await requestJSON(266      url,267      { method: 'POST', headers: this.headers(apiKey), body: JSON.stringify(body) },268      this.provider,269      signal270    )271    let chunk: WireChunk272    try {273      chunk = JSON.parse(responseText) as WireChunk274    } catch {275      throw ProviderError.invalidResponse(this.provider, 'undecodable completion response')276    }277    const choice = chunk.choices?.[0]278    const content = choice?.message ?? choice?.delta279    if (!choice || !content) {280      throw ProviderError.invalidResponse(this.provider, 'response contained no message')281    }282    const { text, reasoning } = splitDelta(content)283    const result: CompletionResult = { text: text !== '' ? text : (choice.text ?? '') }284    if (reasoning !== '') result.reasoning = reasoning285    const citations = citationsFrom(chunk)286    if (citations) result.citations = citations287    if (chunk.usage) result.usage = toUsage(chunk.usage)288    return result289  }290291  /** Non-streaming result assembled from the streaming endpoint. */292  private async completeViaStream(293    request: ChatRequest,294    apiKey: string,295    signal?: AbortSignal296  ): Promise<CompletionResult> {297    let text = ''298    let reasoning = ''299    let citations: Citation[] = []300    let usage: TokenUsage | undefined301    for await (const event of this.streamChat(request, apiKey, signal)) {302      switch (event.type) {303        case 'textDelta':304          text += event.text305          break306        case 'reasoningDelta':307          reasoning += event.text308          break309        case 'citations':310          citations = event.citations311          break312        case 'usage':313          usage = event.usage314          break315        case 'finished':316          break317      }318    }319    const result: CompletionResult = { text }320    if (reasoning !== '') result.reasoning = reasoning321    if (citations.length > 0) result.citations = citations322    if (usage) result.usage = usage323    return result324  }325326  async listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]> {327    const url = joinURL(328      this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined),329      'models'330    )331    const responseText = await requestJSON(332      url,333      { method: 'GET', headers: { Authorization: `Bearer ${apiKey}` } },334      this.provider335    )336    // Together returns a bare array; everyone else wraps in {"data": […]}.337    // Gemini's compat endpoint prefixes IDs with "models/" — normalize.338    let parsed: unknown339    try {340      parsed = JSON.parse(responseText)341    } catch {342      throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')343    }344    let ids: string[]345    if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { data?: unknown }).data)) {346      ids = ((parsed as { data: { id?: unknown }[] }).data ?? [])347        .map((entry) => entry.id)348        .filter((id): id is string => typeof id === 'string')349    } else if (Array.isArray(parsed)) {350      ids = (parsed as { id?: unknown }[])351        .map((entry) => entry.id)352        .filter((id): id is string => typeof id === 'string')353    } else {354      throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape')355    }356    return ids.map((id) => (id.startsWith('models/') ? id.slice(7) : id))357  }358}359360function wireMessage(message: Message, vision: boolean): WireMessage {361  const role = message.role === 'assistant' ? 'assistant' : 'user'362  let text = message.text363  // Text-file attachments are injected inline, fenced with the file name.364  for (const attachment of message.attachments ?? []) {365    if (attachment.kind === 'textFile') {366      text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\``367    }368  }369  const images = (message.attachments ?? []).filter((a) => a.kind === 'image')370  if (!vision || images.length === 0 || message.role !== 'user') {371    return { role, content: text }372  }373  const parts: WirePart[] = [{ type: 'text', text }]374  for (const image of images) {375    parts.push({376      type: 'image_url',377      image_url: { url: `data:${image.mimeType};base64,${image.data}` },378    })379  }380  return { role, content: parts }381}382