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 * types.ts (providers)3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Provider-agnostic client contract, ported from ProviderProtocol.swift.9 * Provider behavior differences never leak above this layer.10 */1112import type {13 AIModel,14 ChatParameters,15 Citation,16 Message,17 Provider,18 TokenUsage,19} from '../types'20import { PROVIDER_META } from './registry'2122/** A provider-agnostic chat request. Clients translate into their wire format. */23export interface ChatRequest {24 model: AIModel25 systemPrompt?: string26 messages: Message[]27 parameters: ChatParameters28 stream: boolean29 /** Per-provider base-URL override (user proxy / Zyquo Router / custom model). */30 baseURLOverride?: string31}3233/** Incremental events surfaced while a response streams. */34export type ChatEvent =35 | { type: 'reasoningDelta'; text: string }36 | { type: 'textDelta'; text: string }37 | { type: 'citations'; citations: Citation[] }38 | { type: 'usage'; usage: TokenUsage }39 | { type: 'finished'; reason: string | null }4041/** The assembled result of a completion. */42export interface CompletionResult {43 text: string44 reasoning?: string45 citations?: Citation[]46 usage?: TokenUsage47}4849/** One cloud AI provider client. */50export interface ProviderClient {51 readonly provider: Provider5253 /** Streams a chat completion; yields ChatEvents, throws ProviderError. */54 streamChat(request: ChatRequest, apiKey: string, signal?: AbortSignal): AsyncGenerator<ChatEvent>5556 /** Non-streaming completion (title generation, key tests, verify harness). */57 complete(request: ChatRequest, apiKey: string, signal?: AbortSignal): Promise<CompletionResult>5859 /** Model IDs currently served by the provider, for dynamic catalog refresh. */60 listModelIDs(apiKey: string, baseURLOverride?: string): Promise<string[]>61}6263export type ProviderErrorKind =64 | 'invalidAPIKey'65 | 'rateLimited'66 | 'serverError'67 | 'badRequest'68 | 'networkError'69 | 'invalidResponse'70 | 'missingAPIKey'71 | 'noModelAvailable'72 | 'cancelled'7374/** Errors mapped to clear, human-readable messages. */75export class ProviderError extends Error {76 readonly kind: ProviderErrorKind77 readonly provider: Provider | null78 readonly status?: number79 readonly retryAfter?: number8081 constructor(82 kind: ProviderErrorKind,83 provider: Provider | null,84 message: string,85 opts: { status?: number; retryAfter?: number } = {}86 ) {87 super(message)88 this.name = 'ProviderError'89 this.kind = kind90 this.provider = provider91 if (opts.status !== undefined) this.status = opts.status92 if (opts.retryAfter !== undefined) this.retryAfter = opts.retryAfter93 }9495 static displayName(provider: Provider | null): string {96 return provider ? PROVIDER_META[provider].displayName : 'the provider'97 }9899 static invalidAPIKey(provider: Provider): ProviderError {100 return new ProviderError(101 'invalidAPIKey',102 provider,103 `Invalid API key for ${ProviderError.displayName(provider)}.`104 )105 }106107 static rateLimited(provider: Provider, retryAfter?: number): ProviderError {108 const suffix =109 retryAfter !== undefined110 ? `retry in ${Math.round(retryAfter)}s.`111 : 'please retry shortly.'112 return new ProviderError(113 'rateLimited',114 provider,115 `${ProviderError.displayName(provider)} rate limited — ${suffix}`,116 retryAfter !== undefined ? { retryAfter } : {}117 )118 }119120 static badRequest(provider: Provider, message: string | null): ProviderError {121 return new ProviderError(122 'badRequest',123 provider,124 `${ProviderError.displayName(provider)} rejected the request${message ? `: ${message}` : ''}.`125 )126 }127128 static serverError(provider: Provider, status: number, message: string | null): ProviderError {129 return new ProviderError(130 'serverError',131 provider,132 `${ProviderError.displayName(provider)} server error (${status})${message ? `: ${message}` : ''}.`,133 { status }134 )135 }136137 static invalidResponse(provider: Provider, detail: string): ProviderError {138 return new ProviderError(139 'invalidResponse',140 provider,141 `Unexpected response from ${ProviderError.displayName(provider)}: ${detail}`142 )143 }144145 static missingAPIKey(provider: Provider): ProviderError {146 return new ProviderError(147 'missingAPIKey',148 provider,149 `No API key configured for ${ProviderError.displayName(provider)}. Add one in Settings → Providers & Keys.`150 )151 }152153 static noModelAvailable(provider: Provider): ProviderError {154 return new ProviderError(155 'noModelAvailable',156 provider,157 `No model available for ${ProviderError.displayName(provider)}.`158 )159 }160161 static cancelled(): ProviderError {162 return new ProviderError('cancelled', null, 'Generation stopped.')163 }164165 /**166 * A fetch TypeError with no HTTP status usually means the request never left167 * the browser (network down) or CORS blocked it — explain the fallback.168 */169 static network(provider: Provider, underlying: unknown): ProviderError {170 const detail = underlying instanceof Error ? underlying.message : String(underlying)171 return new ProviderError(172 'networkError',173 provider,174 `Couldn't reach ${ProviderError.displayName(provider)} (${detail}). ` +175 `If this persists, the provider may be blocking browser calls — ` +176 `set a proxy URL in Settings or run Zyquo Router locally.`177 )178 }179180 /** Maps an HTTP status + provider error body to a typed error. */181 static from(status: number, body: string, provider: Provider, retryAfter?: number): ProviderError {182 const message = extractErrorMessage(body)183 switch (status) {184 case 401:185 case 403:186 return ProviderError.invalidAPIKey(provider)187 case 429:188 return ProviderError.rateLimited(provider, retryAfter)189 case 400:190 case 404:191 case 422:192 return ProviderError.badRequest(provider, message)193 default:194 return ProviderError.serverError(provider, status, message)195 }196 }197}198199/**200 * Providers wrap errors differently ({"error":{"message":…}}, {"message":…},201 * {"error":"…"}, Gemini arrays…). Try the common shapes.202 */203export function extractErrorMessage(body: string): string | null {204 let obj: unknown205 try {206 obj = JSON.parse(body)207 } catch {208 return body.slice(0, 300) || null209 }210 if (obj && typeof obj === 'object' && !Array.isArray(obj)) {211 const dict = obj as Record<string, unknown>212 const err = dict['error']213 if (err && typeof err === 'object' && typeof (err as Record<string, unknown>)['message'] === 'string') {214 return (err as Record<string, unknown>)['message'] as string215 }216 if (typeof err === 'string') return err217 if (typeof dict['message'] === 'string') return dict['message']218 if (typeof dict['detail'] === 'string') return dict['detail']219 }220 if (Array.isArray(obj) && obj.length > 0) {221 const first = obj[0] as Record<string, unknown> | undefined222 const err = first?.['error']223 if (err && typeof err === 'object' && typeof (err as Record<string, unknown>)['message'] === 'string') {224 return (err as Record<string, unknown>)['message'] as string225 }226 }227 return body.slice(0, 300) || null228}229