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 * 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