Files
EasyCLIProxyAPI/src/services/modelService.ts
T

276 lines
11 KiB
TypeScript

import { apiCallErrorMessage, isRecord, managementApi, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrentLocale(), key);
export type ModelOption = {
name: string;
alias?: string;
displayName?: string;
isAlias?: boolean;
contextWindow?: number;
inputModalities?: Array<'text' | 'image'>;
thinking?: Record<string, unknown>;
};
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<ModelOption, 'name' | 'alias' | 'displayName'>,
): string {
return [model.name, model.alias, model.displayName].filter(Boolean).join(' ').toLowerCase();
}
export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] {
const merged = new Map<string, ModelOption>();
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<string>,
mode: ModelSelectionMode,
): Set<string> {
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<string>();
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<string, string> = {},
timeoutMs?: number,
): Promise<ModelOption[]> {
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<string, string> = { ...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<string>();
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<Record<string, unknown>>('/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<Record<string, unknown>>('/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<Record<string, unknown>>('/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'));
}