/* * openaiCompatible.ts * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * One client for every provider speaking the OpenAI /chat/completions schema: * OpenAI, xAI, Mistral, Gemini (compat endpoint), Qwen/DashScope, DeepSeek, * Kimi, Perplexity, Together, DeepInfra, Cerebras, and custom endpoints. * Ported 1:1 from the native OpenAICompatibleClient.swift — all provider * quirks live HERE, nothing leaks above the providers/ layer. */ 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' // --- Wire types (requests) --- type WirePart = | { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } } interface WireMessage { role: string content: string | WirePart[] } interface WireRequestBody { model: string messages: WireMessage[] stream?: boolean stream_options?: { include_usage: boolean } temperature?: number top_p?: number max_tokens?: number max_completion_tokens?: number frequency_penalty?: number presence_penalty?: number reasoning_effort?: string enable_thinking?: boolean } // --- Wire types (responses) --- /** * Mistral reasoning models return `delta.content` as an ARRAY of chunks: * {"type":"text","text":…} or {"type":"thinking","thinking":[{"type":"text","text":…}]}. */ interface WireContentChunk { type?: string text?: string thinking?: { text?: string }[] } interface WireDelta { content?: string | WireContentChunk[] reasoning_content?: string reasoning?: string } interface WireChoice { delta?: WireDelta message?: WireDelta /** Together streams some models completions-style: token text in choices[].text. */ text?: string finish_reason?: string | null } interface WireUsage { prompt_tokens?: number completion_tokens?: number completion_tokens_details?: { reasoning_tokens?: number } } interface WireChunk { choices?: WireChoice[] usage?: WireUsage | null citations?: string[] search_results?: { title?: string; url?: string }[] } /** Splits a WireDelta's content into text + reasoning (Mistral array quirk). */ function splitDelta(delta: WireDelta): { text: string; reasoning: string } { let reasoning = delta.reasoning_content ?? delta.reasoning ?? '' let text = '' if (typeof delta.content === 'string') { text = delta.content } else if (Array.isArray(delta.content)) { const textParts: string[] = [] const thinkingParts: string[] = [] for (const chunk of delta.content) { const flattened = chunk.text ?? (chunk.thinking ?? []).map((p) => p.text ?? '').join('') if (chunk.type === 'thinking') thinkingParts.push(flattened) else textParts.push(flattened) } text = textParts.join('') if (reasoning === '') reasoning = thinkingParts.join('') } return { text, reasoning } } function toUsage(wire: WireUsage): TokenUsage { const reasoningTokens = wire.completion_tokens_details?.reasoning_tokens return { inputTokens: wire.prompt_tokens ?? 0, outputTokens: wire.completion_tokens ?? 0, ...(reasoningTokens !== undefined ? { reasoningTokens } : {}), } } /** * Perplexity: `citations` is an array of URL strings; `search_results` adds * titles. Merge both into numbered citations. */ function citationsFrom(chunk: WireChunk): Citation[] | null { const urls = chunk.citations if (!urls || urls.length === 0) return null const titles = chunk.search_results ?? [] return urls.map((url, index) => { const title = titles[index]?.title return { index: index + 1, url, ...(title ? { title } : {}) } }) } export class OpenAICompatibleClient implements ProviderClient { readonly provider: Provider constructor(provider: Provider) { this.provider = provider } private baseURL(request?: { baseURLOverride?: string; model?: { customBaseURL?: string } }): string { const override = request?.baseURLOverride ?? request?.model?.customBaseURL const base = override ?? PROVIDER_META[this.provider].defaultBaseURL if (!base) { throw ProviderError.invalidResponse(this.provider, 'no base URL configured') } return base } private headers(apiKey: string): Record { return { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', } } private buildBody(request: ChatRequest): WireRequestBody { const messages: WireMessage[] = [] if (request.systemPrompt && request.systemPrompt !== '') { messages.push({ role: 'system', content: request.systemPrompt }) } for (const message of request.messages) { if (message.role === 'system') continue messages.push(wireMessage(message, request.model.capabilities.vision)) } const support = request.model.parameterSupport const params = request.parameters const body: WireRequestBody = { model: request.model.id, messages } if (request.stream) { body.stream = true if (PROVIDER_META[this.provider].wantsStreamOptions) { body.stream_options = { include_usage: true } } } if (support.temperature && params.temperature !== undefined) { body.temperature = params.temperature } if (support.topP && params.topP !== undefined) body.top_p = params.topP if (params.maxTokens !== undefined) { if (support.usesMaxCompletionTokens) body.max_completion_tokens = params.maxTokens else body.max_tokens = params.maxTokens } if (support.frequencyPenalty && params.frequencyPenalty !== undefined) { body.frequency_penalty = params.frequencyPenalty } if (support.presencePenalty && params.presencePenalty !== undefined) { body.presence_penalty = params.presencePenalty } if (support.reasoningEffort && params.reasoningEffort !== undefined) { // Mistral only accepts "high"/"none": map medium→high, low→none. if (this.provider === 'mistral') { body.reasoning_effort = params.reasoningEffort === 'low' ? 'none' : 'high' } else { body.reasoning_effort = params.reasoningEffort } } if (support.thinkingToggle && this.provider === 'qwen') { // DashScope: enable_thinking is only legal on streaming requests. if (request.stream && params.thinkingEnabled !== undefined) { body.enable_thinking = params.thinkingEnabled } } return body } async *streamChat( request: ChatRequest, apiKey: string, signal?: AbortSignal ): AsyncGenerator { const url = joinURL(this.baseURL(request), 'chat/completions') const body = this.buildBody({ ...request, stream: true }) let citationsSent = false let finishReason: string | null = null for await (const event of sseEvents( url, { headers: this.headers(apiKey), body: JSON.stringify(body) }, this.provider, signal )) { if (event.data === '[DONE]') break let chunk: WireChunk try { chunk = JSON.parse(event.data) as WireChunk } catch { continue // tolerate unknown/malformed keep-alive chunks } const choice = chunk.choices?.[0] if (choice) { const { text, reasoning } = choice.delta ? splitDelta(choice.delta) : { text: '', reasoning: '' } if (reasoning !== '') yield { type: 'reasoningDelta', text: reasoning } const deltaText = text !== '' ? text : (choice.text ?? '') if (deltaText !== '') yield { type: 'textDelta', text: deltaText } if (choice.finish_reason != null) finishReason = choice.finish_reason } if (!citationsSent) { const citations = citationsFrom(chunk) if (citations && citations.length > 0) { citationsSent = true yield { type: 'citations', citations } } } if (chunk.usage) yield { type: 'usage', usage: toUsage(chunk.usage) } } yield { type: 'finished', reason: finishReason } } async complete( request: ChatRequest, apiKey: string, signal?: AbortSignal ): Promise { // Some models reject non-streaming calls — aggregate a stream instead. if (request.model.parameterSupport.requiresStreaming) { return this.completeViaStream(request, apiKey, signal) } const url = joinURL(this.baseURL(request), 'chat/completions') 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 chunk: WireChunk try { chunk = JSON.parse(responseText) as WireChunk } catch { throw ProviderError.invalidResponse(this.provider, 'undecodable completion response') } const choice = chunk.choices?.[0] const content = choice?.message ?? choice?.delta if (!choice || !content) { throw ProviderError.invalidResponse(this.provider, 'response contained no message') } const { text, reasoning } = splitDelta(content) const result: CompletionResult = { text: text !== '' ? text : (choice.text ?? '') } if (reasoning !== '') result.reasoning = reasoning const citations = citationsFrom(chunk) if (citations) result.citations = citations if (chunk.usage) result.usage = toUsage(chunk.usage) return result } /** Non-streaming result assembled from the streaming endpoint. */ private async completeViaStream( request: ChatRequest, apiKey: string, signal?: AbortSignal ): Promise { let text = '' let reasoning = '' let citations: Citation[] = [] let usage: TokenUsage | undefined for await (const event of this.streamChat(request, apiKey, signal)) { switch (event.type) { case 'textDelta': text += event.text break case 'reasoningDelta': reasoning += event.text break case 'citations': citations = event.citations break case 'usage': usage = event.usage break case 'finished': break } } const result: CompletionResult = { text } if (reasoning !== '') result.reasoning = reasoning if (citations.length > 0) result.citations = citations if (usage) result.usage = usage return result } async listModelIDs(apiKey: string, baseURLOverride?: string): Promise { const url = joinURL( this.baseURL(baseURLOverride !== undefined ? { baseURLOverride } : undefined), 'models' ) const responseText = await requestJSON( url, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}` } }, this.provider ) // Together returns a bare array; everyone else wraps in {"data": […]}. // Gemini's compat endpoint prefixes IDs with "models/" — normalize. let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape') } let ids: string[] if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { data?: unknown }).data)) { ids = ((parsed as { data: { id?: unknown }[] }).data ?? []) .map((entry) => entry.id) .filter((id): id is string => typeof id === 'string') } else if (Array.isArray(parsed)) { ids = (parsed as { id?: unknown }[]) .map((entry) => entry.id) .filter((id): id is string => typeof id === 'string') } else { throw ProviderError.invalidResponse(this.provider, 'unrecognized /models response shape') } return ids.map((id) => (id.startsWith('models/') ? id.slice(7) : id)) } } function wireMessage(message: Message, vision: boolean): WireMessage { const role = message.role === 'assistant' ? 'assistant' : 'user' let text = message.text // Text-file attachments are injected inline, fenced with the file name. for (const attachment of message.attachments ?? []) { if (attachment.kind === 'textFile') { text += `\n\n\`\`\`${attachment.fileName}\n${attachment.data}\n\`\`\`` } } const images = (message.attachments ?? []).filter((a) => a.kind === 'image') if (!vision || images.length === 0 || message.role !== 'user') { return { role, content: text } } const parts: WirePart[] = [{ type: 'text', text }] for (const image of images) { parts.push({ type: 'image_url', image_url: { url: `data:${image.mimeType};base64,${image.data}` }, }) } return { role, content: parts } }