/* * types.ts (providers) * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * Provider-agnostic client contract, ported from ProviderProtocol.swift. * Provider behavior differences never leak above this layer. */ import type { AIModel, ChatParameters, Citation, Message, Provider, TokenUsage, } from '../types' import { PROVIDER_META } from './registry' /** A provider-agnostic chat request. Clients translate into their wire format. */ export interface ChatRequest { model: AIModel systemPrompt?: string messages: Message[] parameters: ChatParameters stream: boolean /** Per-provider base-URL override (user proxy / Zyquo Router / custom model). */ baseURLOverride?: string } /** Incremental events surfaced while a response streams. */ export type ChatEvent = | { type: 'reasoningDelta'; text: string } | { type: 'textDelta'; text: string } | { type: 'citations'; citations: Citation[] } | { type: 'usage'; usage: TokenUsage } | { type: 'finished'; reason: string | null } /** The assembled result of a completion. */ export interface CompletionResult { text: string reasoning?: string citations?: Citation[] usage?: TokenUsage } /** One cloud AI provider client. */ export interface ProviderClient { readonly provider: Provider /** Streams a chat completion; yields ChatEvents, throws ProviderError. */ streamChat(request: ChatRequest, apiKey: string, signal?: AbortSignal): AsyncGenerator /** Non-streaming completion (title generation, key tests, verify harness). */ complete(request: ChatRequest, apiKey: string, signal?: AbortSignal): Promise /** Model IDs currently served by the provider, for dynamic catalog refresh. */ listModelIDs(apiKey: string, baseURLOverride?: string): Promise } export type ProviderErrorKind = | 'invalidAPIKey' | 'rateLimited' | 'serverError' | 'badRequest' | 'networkError' | 'invalidResponse' | 'missingAPIKey' | 'noModelAvailable' | 'cancelled' /** Errors mapped to clear, human-readable messages. */ export class ProviderError extends Error { readonly kind: ProviderErrorKind readonly provider: Provider | null readonly status?: number readonly retryAfter?: number constructor( kind: ProviderErrorKind, provider: Provider | null, message: string, opts: { status?: number; retryAfter?: number } = {} ) { super(message) this.name = 'ProviderError' this.kind = kind this.provider = provider if (opts.status !== undefined) this.status = opts.status if (opts.retryAfter !== undefined) this.retryAfter = opts.retryAfter } static displayName(provider: Provider | null): string { return provider ? PROVIDER_META[provider].displayName : 'the provider' } static invalidAPIKey(provider: Provider): ProviderError { return new ProviderError( 'invalidAPIKey', provider, `Invalid API key for ${ProviderError.displayName(provider)}.` ) } static rateLimited(provider: Provider, retryAfter?: number): ProviderError { const suffix = retryAfter !== undefined ? `retry in ${Math.round(retryAfter)}s.` : 'please retry shortly.' return new ProviderError( 'rateLimited', provider, `${ProviderError.displayName(provider)} rate limited — ${suffix}`, retryAfter !== undefined ? { retryAfter } : {} ) } static badRequest(provider: Provider, message: string | null): ProviderError { return new ProviderError( 'badRequest', provider, `${ProviderError.displayName(provider)} rejected the request${message ? `: ${message}` : ''}.` ) } static serverError(provider: Provider, status: number, message: string | null): ProviderError { return new ProviderError( 'serverError', provider, `${ProviderError.displayName(provider)} server error (${status})${message ? `: ${message}` : ''}.`, { status } ) } static invalidResponse(provider: Provider, detail: string): ProviderError { return new ProviderError( 'invalidResponse', provider, `Unexpected response from ${ProviderError.displayName(provider)}: ${detail}` ) } static missingAPIKey(provider: Provider): ProviderError { return new ProviderError( 'missingAPIKey', provider, `No API key configured for ${ProviderError.displayName(provider)}. Add one in Settings → Providers & Keys.` ) } static noModelAvailable(provider: Provider): ProviderError { return new ProviderError( 'noModelAvailable', provider, `No model available for ${ProviderError.displayName(provider)}.` ) } static cancelled(): ProviderError { return new ProviderError('cancelled', null, 'Generation stopped.') } /** * A fetch TypeError with no HTTP status usually means the request never left * the browser (network down) or CORS blocked it — explain the fallback. */ static network(provider: Provider, underlying: unknown): ProviderError { const detail = underlying instanceof Error ? underlying.message : String(underlying) return new ProviderError( 'networkError', provider, `Couldn't reach ${ProviderError.displayName(provider)} (${detail}). ` + `If this persists, the provider may be blocking browser calls — ` + `set a proxy URL in Settings or run Zyquo Router locally.` ) } /** Maps an HTTP status + provider error body to a typed error. */ static from(status: number, body: string, provider: Provider, retryAfter?: number): ProviderError { const message = extractErrorMessage(body) switch (status) { case 401: case 403: return ProviderError.invalidAPIKey(provider) case 429: return ProviderError.rateLimited(provider, retryAfter) case 400: case 404: case 422: return ProviderError.badRequest(provider, message) default: return ProviderError.serverError(provider, status, message) } } } /** * Providers wrap errors differently ({"error":{"message":…}}, {"message":…}, * {"error":"…"}, Gemini arrays…). Try the common shapes. */ export function extractErrorMessage(body: string): string | null { let obj: unknown try { obj = JSON.parse(body) } catch { return body.slice(0, 300) || null } if (obj && typeof obj === 'object' && !Array.isArray(obj)) { const dict = obj as Record const err = dict['error'] if (err && typeof err === 'object' && typeof (err as Record)['message'] === 'string') { return (err as Record)['message'] as string } if (typeof err === 'string') return err if (typeof dict['message'] === 'string') return dict['message'] if (typeof dict['detail'] === 'string') return dict['detail'] } if (Array.isArray(obj) && obj.length > 0) { const first = obj[0] as Record | undefined const err = first?.['error'] if (err && typeof err === 'object' && typeof (err as Record)['message'] === 'string') { return (err as Record)['message'] as string } } return body.slice(0, 300) || null }