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%
7.1 KB · 229 lines typescript
Raw Blame History
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