160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
/**
|
|
* 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<FetchedModel[]> {
|
|
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';
|
|
}
|
|
}
|