/** * Model-list fetching for OpenAI-compatible providers. * * Ported from cc-switch `src-tauri/src/services/model_fetch.rs`. The core value * is `buildModelsUrlCandidates`: token-plan / aggregator base URLs come in many * shapes, so we generate an ordered candidate list (with an Anthropic-compat * suffix-strip fallback) and try each until one returns a model list. */ import { fetchWithTimeout } from './fetch-with-timeout'; /** A model id discovered from a provider's /models endpoint. */ export interface FetchedModel { id: string; ownedBy?: string; } /** * Known "Anthropic-compatible subpath" suffixes. When a base URL ends with one * of these, candidates also include the suffix-stripped root + /v1/models and * /models. Ordered longest-first so `/api/anthropic` wins over `/anthropic`. */ const KNOWN_COMPAT_SUFFIXES = [ '/api/claudecode', '/api/anthropic', '/apps/anthropic', '/api/coding', '/claudecode', '/anthropic', '/step_plan', '/coding', '/claude', ] as const; const FETCH_TIMEOUT_MS = 15_000; /** Whether the URL's last path segment is an OpenAI-style version segment `/v{N}`. */ function endsWithVersionSegment(url: string): boolean { const last = url.split('/').pop() ?? ''; if (!last.startsWith('v')) return false; const digits = last.slice(1); return digits.length > 0 && /^\d+$/.test(digits); } /** If the URL ends with a known compat suffix, returns the stripped remainder. */ function stripCompatSuffix(baseUrl: string): string | null { for (const suffix of KNOWN_COMPAT_SUFFIXES) { if (baseUrl.endsWith(suffix)) { return baseUrl.slice(0, baseUrl.length - suffix.length); } } return null; } /** * Builds the ordered list of candidate `/models` URLs for a base URL. * * Order: * 1. `modelsUrlOverride` (if provided) — sole candidate * 2. `{base}/v1/models`; or `{base}/models` when base ends in a version segment * (`/v1`, `.../paas/v4`), plus `{base}/v1/models` fallback when that segment * is not `/v1` * 3. If base hits a known Anthropic-compat suffix, the stripped root + * `/v1/models` and `/models` * * Deduped, order-preserving. Throws on an empty base URL. */ export function buildModelsUrlCandidates( baseUrl: string, opts: { modelsUrlOverride?: string } = {}, ): string[] { const override = opts.modelsUrlOverride?.trim(); if (override) return [override]; const trimmed = baseUrl.trim().replace(/\/+$/, ''); if (!trimmed) throw new Error('Base URL is empty'); const candidates: string[] = []; if (endsWithVersionSegment(trimmed)) { candidates.push(`${trimmed}/models`); if (!trimmed.endsWith('/v1')) { candidates.push(`${trimmed}/v1/models`); } } else { candidates.push(`${trimmed}/v1/models`); } const stripped = stripCompatSuffix(trimmed); if (stripped) { const root = stripped.replace(/\/+$/, ''); if (root && root.includes('://')) { candidates.push(`${root}/v1/models`); candidates.push(`${root}/models`); } } // Linear dedupe preserving first occurrence (≤4 candidates). return candidates.filter((url, i) => candidates.indexOf(url) === i); } interface ModelsApiResponse { data?: Array<{ id: string; owned_by?: string }>; } /** * Fetches the model list by trying each candidate URL in order. A 404/405 means * "wrong path" and moves on to the next candidate; any other non-2xx is returned * as an error immediately (e.g. 401 = bad key, surfaced to the caller verbatim). * * Throws on network failure or when all candidates 404. The caller (probe route) * is responsible for SSRF validation of `baseUrl` before calling this. */ export async function fetchModels( baseUrl: string, apiKey: string, opts: { modelsUrlOverride?: string } = {}, ): Promise { const candidates = buildModelsUrlCandidates(baseUrl, opts); for (const url of candidates) { const res = await fetchWithTimeout( url, { method: 'GET', headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, }, FETCH_TIMEOUT_MS, ); if (res.ok) { const body = (await res.json()) as ModelsApiResponse; return (body.data ?? []) .map((m) => ({ id: m.id, ownedBy: m.owned_by })) .sort((a, b) => a.id.localeCompare(b.id)); } if (res.status === 404 || res.status === 405) { continue; } // Other statuses (401/403/5xx) are terminal — surface the body for context. const text = await res.text().catch(() => ''); throw new ModelFetchError(res.status, `HTTP ${res.status}: ${text.slice(0, 512)}`); } throw new ModelFetchError(404, `No /models endpoint found (tried: ${candidates.join(', ')})`); } /** Error carrying the upstream HTTP status so the route can map it (401 vs 404). */ export class ModelFetchError extends Error { constructor( public readonly status: number, message: string, ) { super(message); this.name = 'ModelFetchError'; } }