276 lines
11 KiB
TypeScript
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'));
|
|
}
|