返回 presentation-ai
model-picker.ts
根目录 / src / lib / model-picker.ts
1 import { env } from "@/env";
2 import { createLogger } from "@/lib/observability/logger";
3 import { ChatOpenAI } from "@langchain/openai";
4
5 type ModelProvider = "openai" | "ollama" | "lmstudio";
6 const modelLogger = createLogger("model-picker");
7 const OLLAMA_BASE_URL = "http://localhost:11434";
8 const OLLAMA_TAGS_URL = `${OLLAMA_BASE_URL}/api/tags`;
9 const OLLAMA_PULL_URL = `${OLLAMA_BASE_URL}/api/pull`;
10 const LM_STUDIO_BASE_URL = "http://localhost:1234";
11 const LM_STUDIO_API_BASE_URL = `${LM_STUDIO_BASE_URL}/v1`;
12 const LM_STUDIO_MODELS_URLS = [
13 `${LM_STUDIO_API_BASE_URL}/models`,
14 `${LM_STUDIO_BASE_URL}/api/v0/models`,
15 ] as const;
16
17 interface OllamaTagsResponse {
18 models?: Array<{ name?: string }>;
19 }
20
21 interface OllamaPullProgressChunk {
22 status?: string;
23 error?: string;
24 completed?: number;
25 total?: number;
26 }
27
28 function extractLMStudioModelIds(payload: unknown): string[] {
29 const candidateArrays: unknown[][] = [
30 Array.isArray((payload as { data?: unknown[] } | null)?.data)
31 ? ((payload as { data: unknown[] }).data ?? [])
32 : [],
33 Array.isArray((payload as { models?: unknown[] } | null)?.models)
34 ? ((payload as { models: unknown[] }).models ?? [])
35 : [],
36 Array.isArray(payload) ? payload : [],
37 ];
38
39 const modelIds = candidateArrays.flatMap((candidates) =>
40 candidates.flatMap((candidate) => {
41 if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
42 return [];
43 }
44
45 const record = candidate as Record<string, unknown>;
46 const modelId = [record.id, record.model, record.modelKey, record.name].find(
47 (value) => typeof value === "string" && value.trim().length > 0,
48 );
49
50 return typeof modelId === "string" ? [modelId.trim()] : [];
51 }),
52 );
53
54 return [...new Set(modelIds)];
55 }
56
57 function isModelProvider(value: string): value is ModelProvider {
58 return value === "openai" || value === "ollama" || value === "lmstudio";
59 }
60
61 function resolveModelSelection(
62 modelProviderOrModel: string,
63 modelId?: string,
64 ): {
65 provider: ModelProvider;
66 modelId?: string;
67 } {
68 if (isModelProvider(modelProviderOrModel)) {
69 return {
70 provider: modelProviderOrModel,
71 modelId,
72 };
73 }
74
75 return {
76 provider: "openai",
77 modelId: modelProviderOrModel,
78 };
79 }
80
81 async function fetchInstalledOllamaModels(): Promise<Set<string>> {
82 const response = await fetch(OLLAMA_TAGS_URL, {
83 method: "GET",
84 cache: "no-store",
85 });
86
87 if (!response.ok) {
88 throw new Error("Ollama is not available. Start Ollama and try again.");
89 }
90
91 const data = (await response.json()) as OllamaTagsResponse;
92 const installedModels = new Set(
93 (data.models ?? [])
94 .map((model) => model.name?.trim())
95 .filter((name): name is string => Boolean(name)),
96 );
97
98 return installedModels;
99 }
100
101 async function fetchInstalledLMStudioModels(): Promise<Set<string>> {
102 let lastError: Error | null = null;
103 let receivedResponse = false;
104
105 for (const url of LM_STUDIO_MODELS_URLS) {
106 try {
107 const response = await fetch(url, {
108 method: "GET",
109 cache: "no-store",
110 });
111
112 if (!response.ok) {
113 throw new Error(`LM Studio responded with ${response.status}`);
114 }
115
116 receivedResponse = true;
117 const modelIds = extractLMStudioModelIds(await response.json());
118
119 modelLogger.info("Fetched LM Studio model catalog", {
120 provider: "lmstudio",
121 source: url,
122 count: modelIds.length,
123 });
124
125 return new Set(modelIds);
126 } catch (error) {
127 lastError = error instanceof Error ? error : new Error(String(error));
128 }
129 }
130
131 if (receivedResponse) {
132 return new Set();
133 }
134
135 throw new Error(
136 lastError?.message ??
137 "LM Studio is not available. Start LM Studio and try again.",
138 );
139 }
140
141 async function ensureOllamaModelIsReady(modelId: string): Promise<void> {
142 const installedModels = await fetchInstalledOllamaModels();
143 if (installedModels.has(modelId)) {
144 modelLogger.info("Ollama model already installed", {
145 provider: "ollama",
146 modelId,
147 });
148 return;
149 }
150
151 modelLogger.info("Ollama model missing; starting download", {
152 provider: "ollama",
153 modelId,
154 });
155
156 const response = await fetch(OLLAMA_PULL_URL, {
157 method: "POST",
158 headers: {
159 "Content-Type": "application/json",
160 },
161 body: JSON.stringify({
162 name: modelId,
163 stream: true,
164 }),
165 });
166
167 if (!response.ok) {
168 throw new Error(
169 `Failed to download Ollama model "${modelId}". Ensure Ollama is running and try again.`,
170 );
171 }
172
173 if (!response.body) {
174 throw new Error(
175 `Ollama did not return a download stream for model "${modelId}".`,
176 );
177 }
178
179 const reader = response.body.getReader();
180 const decoder = new TextDecoder();
181 let buffer = "";
182 let lastStatus: string | undefined;
183 const processProgressLine = (line: string): void => {
184 let chunk: OllamaPullProgressChunk;
185 try {
186 chunk = JSON.parse(line) as OllamaPullProgressChunk;
187 } catch (error) {
188 modelLogger.warn("Failed to parse Ollama pull progress chunk", {
189 provider: "ollama",
190 modelId,
191 line,
192 error: error instanceof Error ? error.message : String(error),
193 });
194 return;
195 }
196
197 if (chunk.error) {
198 throw new Error(
199 `Failed to download Ollama model "${modelId}": ${chunk.error}`,
200 );
201 }
202
203 if (chunk.status) {
204 lastStatus = chunk.status;
205 }
206 };
207
208 while (true) {
209 const { done, value } = await reader.read();
210
211 if (done) {
212 break;
213 }
214
215 buffer += decoder.decode(value, { stream: true });
216 const lines = buffer.split("\n");
217 buffer = lines.pop() ?? "";
218
219 for (const rawLine of lines) {
220 const line = rawLine.trim();
221 if (!line) {
222 continue;
223 }
224 processProgressLine(line);
225 }
226 }
227
228 if (buffer.trim()) {
229 processProgressLine(buffer.trim());
230 }
231
232 const refreshedModels = await fetchInstalledOllamaModels();
233 if (!refreshedModels.has(modelId)) {
234 throw new Error(
235 `Ollama finished downloading "${modelId}" but the model is still unavailable. Last status: ${lastStatus ?? "unknown"}.`,
236 );
237 }
238
239 modelLogger.info("Ollama model download completed", {
240 provider: "ollama",
241 modelId,
242 lastStatus: lastStatus ?? "unknown",
243 });
244 }
245
246 async function ensureLMStudioModelIsReady(modelId: string): Promise<void> {
247 const availableModels = await fetchInstalledLMStudioModels();
248
249 if (availableModels.has(modelId)) {
250 modelLogger.info("LM Studio model is available", {
251 provider: "lmstudio",
252 modelId,
253 });
254 return;
255 }
256
257 if (availableModels.size === 0) {
258 throw new Error(
259 `LM Studio is running but no models are currently available. Load "${modelId}" in LM Studio and try again.`,
260 );
261 }
262
263 throw new Error(
264 `LM Studio model "${modelId}" is not available. Load it in LM Studio and make sure the local server is running.`,
265 );
266 }
267
268 export function assertModelIsConfigured(
269 modelProviderOrModel: string,
270 modelId?: string,
271 ) {
272 const selection = resolveModelSelection(modelProviderOrModel, modelId);
273 const selectedOpenAIModel = selection.modelId || "gpt-4o-mini";
274 const selectedLocalModel = selection.modelId?.trim();
275
276 if (selection.provider === "ollama" && !selectedLocalModel) {
277 modelLogger.error("Model configuration failed", undefined, {
278 provider: selection.provider,
279 reason: "missing_model_id",
280 });
281 throw new Error("An Ollama model must be selected before continuing.");
282 }
283
284 if (selection.provider === "lmstudio" && !selectedLocalModel) {
285 modelLogger.error("Model configuration failed", undefined, {
286 provider: selection.provider,
287 reason: "missing_model_id",
288 });
289 throw new Error("An LM Studio model must be selected before continuing.");
290 }
291
292 if (selection.provider === "openai" && !env.OPENAI_API_KEY?.trim()) {
293 modelLogger.error("Model configuration failed", undefined, {
294 provider: selection.provider,
295 modelId: selectedOpenAIModel,
296 reason: "missing_openai_api_key",
297 });
298 throw new Error(
299 `OPENAI_API_KEY is required when using the OpenAI model "${selectedOpenAIModel}".`,
300 );
301 }
302
303 modelLogger.info("Model configuration validated", {
304 provider: selection.provider,
305 modelId:
306 selection.provider === "openai"
307 ? selectedOpenAIModel
308 : selectedLocalModel || undefined,
309 });
310 }
311
312 export async function ensureModelIsReady(
313 modelProviderOrModel: string,
314 modelId?: string,
315 ) {
316 const selection = resolveModelSelection(modelProviderOrModel, modelId);
317 if (!selection.modelId) {
318 return;
319 }
320
321 if (selection.provider === "ollama") {
322 await ensureOllamaModelIsReady(selection.modelId);
323 return;
324 }
325
326 if (selection.provider === "lmstudio") {
327 await ensureLMStudioModelIsReady(selection.modelId);
328 }
329 }
330
331 /**
332 * Centralized model picker for LangChain-based presentation routes.
333 * Supports OpenAI and OpenAI-compatible local endpoints.
334 */
335 export function modelPicker(modelProviderOrModel: string, modelId?: string) {
336 const selection = resolveModelSelection(modelProviderOrModel, modelId);
337
338 if (selection.provider === "lmstudio") {
339 if (!selection.modelId) {
340 throw new Error("An LM Studio model must be selected before continuing.");
341 }
342
343 modelLogger.info("Creating LM Studio model client", {
344 provider: selection.provider,
345 modelId: selection.modelId,
346 baseUrl: LM_STUDIO_API_BASE_URL,
347 });
348
349 return new ChatOpenAI({
350 model: selection.modelId,
351 apiKey: "lmstudio",
352 configuration: {
353 baseURL: LM_STUDIO_API_BASE_URL,
354 },
355 });
356 }
357
358 if (selection.provider === "ollama") {
359 if (!selection.modelId) {
360 throw new Error("An Ollama model must be selected before continuing.");
361 }
362
363 modelLogger.info("Creating Ollama model client", {
364 provider: selection.provider,
365 modelId: selection.modelId,
366 baseUrl: `${OLLAMA_BASE_URL}/v1`,
367 });
368
369 return new ChatOpenAI({
370 model: selection.modelId,
371 apiKey: "ollama",
372 configuration: {
373 baseURL: `${OLLAMA_BASE_URL}/v1`,
374 },
375 });
376 }
377
378 const selectedOpenAIModel = selection.modelId || "gpt-4o-mini";
379 const openAIApiKey = env.OPENAI_API_KEY?.trim();
380
381 modelLogger.info("Creating OpenAI model client", {
382 provider: selection.provider,
383 modelId: selectedOpenAIModel,
384 hasApiKey: Boolean(openAIApiKey),
385 });
386
387 return new ChatOpenAI({
388 model: selectedOpenAIModel,
389 ...(openAIApiKey ? { apiKey: openAIApiKey } : {}),
390 });
391 }
392
392 lines TYPESCRIPT