feat(agent): add llm model discovery helper

This commit is contained in:
ginnoir
2026-07-08 16:11:22 -05:00
parent eb8e562565
commit 0bf63cf8a8
2 changed files with 254 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import { getLlmConfig, type LlmConfig } from "./config";
export type LlmModelOption = {
id: string;
label: string;
};
export type LlmModelsResult = {
models: LlmModelOption[];
fallbackModel: string;
degraded: boolean;
};
export type AssistantModelResolution =
| { ok: true; model: string }
| { ok: false; model: string; error: string };
const MODEL_ID_PATTERN = /^[A-Za-z0-9._:/-]+$/;
const MAX_MODEL_ID_LENGTH = 128;
export function isValidLlmModelId(value: string): boolean {
const trimmed = value.trim();
return (
trimmed.length > 0 &&
trimmed.length <= MAX_MODEL_ID_LENGTH &&
trimmed === value &&
MODEL_ID_PATTERN.test(trimmed)
);
}
export function normalizeLlmModelsPayload(payload: unknown): LlmModelOption[] {
const data =
typeof payload === "object" && payload !== null && "data" in payload
? (payload as { data?: unknown }).data
: null;
if (!Array.isArray(data)) return [];
const ids = new Set<string>();
for (const row of data) {
if (typeof row !== "object" || row === null || !("id" in row)) continue;
const id = (row as { id?: unknown }).id;
if (typeof id !== "string") continue;
const trimmed = id.trim();
if (!isValidLlmModelId(trimmed)) continue;
ids.add(trimmed);
}
return [...ids].sort((a, b) => a.localeCompare(b)).map((id) => ({ id, label: id }));
}
export async function listLlmModels(options?: {
config?: LlmConfig;
fetchImpl?: typeof fetch;
}): Promise<LlmModelsResult> {
const config = options?.config ?? getLlmConfig();
const fetchImpl = options?.fetchImpl ?? fetch;
const fallbackModel = config.model;
const fallbackOption = { id: fallbackModel, label: fallbackModel };
if (config.provider === "mock" || !config.baseUrl) {
return { models: [fallbackOption], fallbackModel, degraded: false };
}
try {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await fetchImpl(`${config.baseUrl.replace(/\/$/, "")}/models`, {
method: "GET",
headers,
});
if (!response.ok) {
return { models: [fallbackOption], fallbackModel, degraded: true };
}
const models = normalizeLlmModelsPayload(await response.json());
const merged = new Map<string, LlmModelOption>();
merged.set(fallbackModel, fallbackOption);
for (const model of models) merged.set(model.id, model);
return {
models: [...merged.values()].sort((a, b) => a.id.localeCompare(b.id)),
fallbackModel,
degraded: models.length === 0,
};
} catch {
return { models: [fallbackOption], fallbackModel, degraded: true };
}
}
export function resolveAssistantModel(options: {
requestedModel: string | null | undefined;
savedModel: string | null | undefined;
fallbackModel: string;
models: LlmModelOption[];
}): AssistantModelResolution {
const available = new Set(options.models.map((model) => model.id));
const fallback = available.has(options.fallbackModel)
? options.fallbackModel
: (options.models[0]?.id ?? options.fallbackModel);
if (options.requestedModel) {
if (!available.has(options.requestedModel)) {
return { ok: false, model: fallback, error: "Invalid assistant model" };
}
return { ok: true, model: options.requestedModel };
}
if (options.savedModel && available.has(options.savedModel)) {
return { ok: true, model: options.savedModel };
}
return { ok: true, model: fallback };
}