import { apiCallErrorMessage, isRecord, managementApi, readString } from './managementApi'; import { getCurrentLocale, translate } from '../i18n'; const modelText = (key: Parameters[1]) => translate(getCurrentLocale(), key); export type ModelOption = { name: string; alias?: string; displayName?: string; isAlias?: boolean; contextWindow?: number; inputModalities?: Array<'text' | 'image'>; thinking?: Record; }; export type ModelProvider = 'gemini' | 'codex' | 'deepseek' | 'claude' | 'openai'; export type ModelSelectionMode = 'initial' | 'refresh'; const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com'; const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com'; export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'; const modelKey = (name: string) => name.trim().toLowerCase(); export function usableModelAlias(value: string | undefined | null): string { const alias = value?.trim() ?? ''; if (!alias || alias.length > 240) return ''; for (const character of alias) { const code = character.charCodeAt(0); if (character.trim() === '' || code < 32 || code === 127) return ''; } return alias; } export function modelSearchText( model: Pick, ): string { return [model.name, model.alias, model.displayName].filter(Boolean).join(' ').toLowerCase(); } export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] { const merged = new Map(); groups.flat().forEach((model) => { const name = model.name.trim(); if (!name) return; const previous = merged.get(modelKey(name)); const next: ModelOption = { ...previous, ...model, name }; const alias = (model.alias ?? '').trim() || previous?.alias; const displayName = (model.displayName ?? '').trim() || previous?.displayName; if (alias && alias !== name) next.alias = alias; else delete next.alias; if (displayName && displayName !== name) next.displayName = displayName; else delete next.displayName; merged.set(modelKey(name), next); }); return Array.from(merged.values()); } export function reconcileModelSelection( discoveredModels: ModelOption[], configuredModels: ModelOption[], selectedModelNames: Iterable, mode: ModelSelectionMode, ): Set { const availableNames = new Set( mergeModelOptions(discoveredModels, configuredModels).map((model) => modelKey(model.name)), ); const configuredNames = new Set( configuredModels.map((model) => modelKey(model.name)).filter(Boolean), ); const previousSelection = new Set( Array.from(selectedModelNames, modelKey).filter(Boolean), ); const requestedSelection = mode === 'refresh' ? previousSelection : configuredNames.size > 0 ? configuredNames : new Set(discoveredModels.map((model) => modelKey(model.name)).filter(Boolean)); return new Set(Array.from(requestedSelection).filter((name) => availableNames.has(name))); } export function normalizeBaseUrl(value: string): string { let raw = value.trim(); if (!raw) return ''; if (!/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) raw = `https://${raw}`; let parsed: URL; try { parsed = new URL(raw); } catch { throw new Error(modelText('model.error.invalidBaseUrl')); } if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) { throw new Error(modelText('model.error.unsupportedBaseUrl')); } parsed.hash = ''; parsed.search = ''; return parsed.toString() .replace(/\/(?:chat\/completions|messages|responses|generateContent)$/i, '') .replace(/\/+$/, ''); } const stripKnownSuffix = (baseUrl: string) => normalizeBaseUrl(baseUrl) .replace(/\/(?:v1beta|v1)\/models$/i, '') .replace(/\/models$/i, ''); export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string): string[] => { const resolvedBaseUrl = baseUrl.trim() || (provider === 'gemini' ? DEFAULT_GEMINI_BASE_URL : provider === 'claude' ? DEFAULT_CLAUDE_BASE_URL : provider === 'deepseek' ? DEEPSEEK_BASE_URL : ''); const normalized = normalizeBaseUrl(resolvedBaseUrl); if (!normalized) return []; if (provider === 'openai') { return [/\/models$/i.test(normalized) ? normalized : `${normalized}/models`]; } const base = stripKnownSuffix(normalized); const withoutVersion = base.replace(/\/(?:v1beta|v1)$/i, ''); if (provider === 'gemini') return [`${withoutVersion}/v1beta/models`]; if (provider === 'claude') return [`${withoutVersion}/v1/models`]; if (provider === 'deepseek') return [`${base}/models`]; return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`]; }; const normalizeModelList = (payload: unknown, preserveExistingAlias = false): ModelOption[] => { const parsed = typeof payload === 'string' ? (() => { try { return JSON.parse(payload) as unknown; } catch { return payload; } })() : payload; const source = isRecord(parsed) ? (Array.isArray(parsed.data) ? parsed.data : Array.isArray(parsed.models) ? parsed.models : []) : Array.isArray(parsed) ? parsed : []; const seen = new Set(); return source.map((item): ModelOption | null => { const name = typeof item === 'string' ? item : isRecord(item) ? readString(item, 'id', 'name', 'model', 'value') : ''; if (!name || seen.has(name.toLowerCase())) return null; seen.add(name.toLowerCase()); const record = typeof item === 'object' && isRecord(item) ? item : null; const rawAlias = record ? readString(record, 'alias') : ''; const alias = preserveExistingAlias ? rawAlias : usableModelAlias(rawAlias); const displayName = record ? readString(record, 'display-name', 'display_name', 'displayName') : ''; const thinking = record && isRecord(record.thinking) ? { ...record.thinking } : undefined; return { name, ...(alias && alias !== name ? { alias } : {}), ...(displayName && displayName !== name ? { displayName } : {}), ...(thinking ? { thinking } : {}), }; }).filter((item): item is ModelOption => item !== null); }; export function modelsFromRecord(value: unknown): ModelOption[] { if (!Array.isArray(value)) return []; return normalizeModelList(value, true); } export function modelsFromDiscoveredPayload(payload: unknown): ModelOption[] { return normalizeModelList(payload); } export async function fetchModels( provider: ModelProvider, baseUrl: string, apiKey: string, authIndex?: string, customHeaders: Record = {}, timeoutMs?: number, ): Promise { const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : ''; const candidates = modelEndpointCandidates(provider, normalized); if (candidates.length === 0) throw new Error(modelText('model.error.baseUrlRequired')); const headers: Record = { ...customHeaders }; const hasHeader = (name: string) => Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase()); const headerValue = (name: string) => Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? ''; const key = apiKey.trim(); if (provider === 'gemini') { if (key && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = key; else if (authIndex && !hasHeader('x-goog-api-key')) headers['x-goog-api-key'] = '$TOKEN$'; } else if (provider === 'claude') { const bearerToken = headerValue('authorization').match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? ''; if (key && !hasHeader('x-api-key')) headers['x-api-key'] = key; else if (bearerToken && !hasHeader('x-api-key')) headers['x-api-key'] = bearerToken; else if (authIndex && !hasHeader('x-api-key')) headers['x-api-key'] = '$TOKEN$'; if (!hasHeader('anthropic-version')) headers['anthropic-version'] = '2023-06-01'; } else if (key && !hasHeader('authorization')) { headers.Authorization = `Bearer ${key}`; } else if (authIndex && !hasHeader('authorization')) { headers.Authorization = 'Bearer $TOKEN$'; } let lastError = ''; for (const url of candidates) { try { const collected: ModelOption[] = []; const seen = new Set(); let pageToken = ''; for (let page = 0; page < (provider === 'gemini' ? 20 : 1); page += 1) { const pageUrl = new URL(url); if (pageToken) pageUrl.searchParams.set('pageToken', pageToken); const response = await managementApi.post>('/api-call', { authIndex: authIndex?.trim() || undefined, method: 'GET', url: pageUrl.toString(), header: Object.keys(headers).length ? headers : undefined, }, { timeoutMs }); const status = Number(response.status_code ?? response.statusCode ?? 0); if (status < 200 || status >= 300) { lastError = apiCallErrorMessage(response); break; } const payload = response.body ?? response.bodyText; modelsFromDiscoveredPayload(payload).forEach((model) => { const name = provider === 'gemini' ? model.name.replace(/^models\//i, '') : model.name; const dedupeKey = name.toLowerCase(); if (!name || seen.has(dedupeKey)) return; seen.add(dedupeKey); collected.push( name === model.name ? model : { ...model, name, alias: model.alias === model.name ? undefined : model.alias }, ); }); const parsedPayload = typeof payload === 'string' ? (() => { try { return JSON.parse(payload) as unknown; } catch { return null; } })() : payload; pageToken = isRecord(parsedPayload) ? readString(parsedPayload, 'nextPageToken') : ''; if (!pageToken) break; } if (collected.length) return collected; if (provider === 'openai' && Object.keys(headers).length > 0) { const response = await managementApi.post>('/api-call', { method: 'GET', url, }, { timeoutMs }); const status = Number(response.status_code ?? response.statusCode ?? 0); if (status >= 200 && status < 300) { const models = modelsFromDiscoveredPayload(response.body ?? response.bodyText); if (models.length) return models; } } } catch (error) { lastError = String(error); if (provider === 'openai' && Object.keys(headers).length > 0) { try { const response = await managementApi.post>('/api-call', { method: 'GET', url, }, { timeoutMs }); const status = Number(response.status_code ?? response.statusCode ?? 0); if (status >= 200 && status < 300) { const models = modelsFromDiscoveredPayload(response.body ?? response.bodyText); if (models.length) return models; } } catch { } } } } throw new Error(lastError || modelText('model.error.noResponse')); }