(base64: string, mime: string, prompt: string)
| 275 | } |
| 276 | |
| 277 | async function callLocal(base64: string, mime: string, prompt: string): Promise<string> { |
| 278 | // CRITICAL: only call this if the user has EXPLICITLY configured a vision model. |
| 279 | // Otherwise the request hits whatever text model is currently loaded in LM Studio |
| 280 | // (e.g. Qwen3-Coder, which is text-only), and the model returns a confused |
| 281 | // "I can't see images" response that LOOKS like a real analysis to the parent agent. |
| 282 | // |
| 283 | // We therefore require QODEX_LOCAL_VISION_MODEL to be set. The default LM Studio |
| 284 | // port is fine, but the model id MUST be explicit — there's no way for us to |
| 285 | // tell from the response alone whether the loaded model supports vision. |
| 286 | const explicitModel = resolveLocalVisionModel(); |
| 287 | if (!explicitModel) { |
| 288 | throw new Error( |
| 289 | 'Local vision backend not configured. Set QODEX_LOCAL_VISION_MODEL to the id ' + |
| 290 | 'of a vision-capable model loaded in LM Studio (e.g. "qwen2.5-vl-7b" or "qwen3-vl-32b"), ' + |
| 291 | 'point roles.vision at an openai-provider model, or run a vision-capable primary model. ' + |
| 292 | 'Without this, requests would hit your text model and return fake analysis.', |
| 293 | ); |
| 294 | } |
| 295 | const baseUrl = process.env.QODEX_LOCAL_VISION_URL ?? 'http://127.0.0.1:1234/v1'; |
| 296 | const res = await fetch(`${baseUrl}/chat/completions`, { |
| 297 | method: 'POST', |
| 298 | headers: { 'Content-Type': 'application/json' }, |
| 299 | body: JSON.stringify({ |
| 300 | model: explicitModel, |
| 301 | max_tokens: 1024, |
| 302 | messages: [ |
| 303 | { |
| 304 | role: 'user', |
| 305 | content: [ |
| 306 | { type: 'text', text: prompt }, |
| 307 | { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } }, |
| 308 | ], |
| 309 | }, |
| 310 | ], |
| 311 | }), |
| 312 | }); |
| 313 | if (!res.ok) { |
| 314 | const errText = await res.text().catch(() => ''); |
| 315 | throw new Error(`Local vision backend HTTP ${res.status}: ${errText.slice(0, 300)}. Ensure a vision-capable model is loaded in LM Studio.`); |
| 316 | } |
| 317 | const body = await res.json() as { choices?: Array<{ message?: { content?: string } }> }; |
| 318 | const content = body.choices?.[0]?.message?.content ?? ''; |
| 319 | |
| 320 | // Heuristic: if the response sounds like a text-only model apologizing about not |
| 321 | // being able to see images, treat it as a backend failure so the chain falls through. |
| 322 | // This catches the case where the user set QODEX_LOCAL_VISION_MODEL but actually |
| 323 | // a non-vision model is loaded under that id (LM Studio doesn't enforce capabilities). |
| 324 | if (looksLikeTextOnlyRefusal(content)) { |
| 325 | throw new Error( |
| 326 | `Local model '${explicitModel}' responded as if it can't see images. ` + |
| 327 | `Verify a real vision model is loaded in LM Studio (Qwen2.5-VL, Qwen3-VL, etc) — ` + |
| 328 | `the model id alone isn't enough; the loaded weights must support vision.`, |
| 329 | ); |
| 330 | } |
| 331 | return content; |
| 332 | } |
| 333 | |
| 334 | /** |
no test coverage detected