feat(llm): auto-detect max images per request for LLM providers

Add binary-search probe endpoint POST /providers/{id}/probe-max-images
that discovers the per-request image limit by sending incrementally
more 1x1 JPEGs via the provider's own API. Result is cached in the
new max_images column on the provider config.

- LLMProviderConfig: add max_images: int | None
- LLMProvider (SQLAlchemy): add max_images column
- Migration ed28d34edde7: clean ADD COLUMN
- LLMProviderService: create/update/set_max_images
- POST /providers/{id}/probe-max-images: binary search + error parsing
- ProviderConfig.svelte: 'Detect' button in edit modal + HelpTooltip
- i18n (en/ru): 11 new keys for probe UI
This commit is contained in:
2026-05-31 22:31:36 +03:00
parent 4dcc09d621
commit 7a3d0f9766
8 changed files with 414 additions and 13 deletions

View File

@@ -13,6 +13,7 @@
import { t } from "../../lib/i18n";
import { requestApi } from "../../lib/api";
import { addToast } from "../../lib/toasts.js";
import HelpTooltip from "$lib/ui/HelpTooltip.svelte";
let {
providers = [],
@@ -37,6 +38,7 @@
default_model: "gpt-4o",
is_active: true,
is_multimodal: false,
max_images: null,
});
let testStatus = $state({ type: "", message: "" });
@@ -51,6 +53,11 @@
let fetchDebounceTimer = $state(null);
// Probing state
let isProbing = $state(false);
let probeResult = $state(null); // { max_images, method } or null
let probeError = $state("");
function isMaskedKey(key) {
if (!key) return false;
return key === "********" || key.includes("...");
@@ -75,11 +82,14 @@
default_model: "gpt-4o",
is_active: true,
is_multimodal: false,
max_images: null,
};
editingProvider = null;
testStatus = { type: "", message: "" };
availableModels = [];
modelsLoadError = "";
probeResult = null;
probeError = "";
}
function handleEdit(provider) {
@@ -97,10 +107,13 @@
default_model: provider?.default_model ?? "gpt-4o",
is_active: Boolean(provider?.is_active),
is_multimodal: Boolean(provider?.is_multimodal),
max_images: provider?.max_images ?? null,
};
testStatus = { type: "", message: "" };
availableModels = [];
modelsLoadError = "";
probeResult = null;
probeError = "";
showForm = true;
// Auto-fetch models when editing an existing provider
if (provider?.base_url) {
@@ -205,6 +218,44 @@
}
}
async function detectMaxImages() {
const providerId = editingProvider?.id;
if (!providerId) {
addToast($t.llm.save_before_probe, "warning");
return;
}
console.log("[ProviderConfig][Action] Detecting max images for", providerId);
isProbing = true;
probeError = "";
probeResult = null;
try {
const result = await requestApi(`/llm/providers/${providerId}/probe-max-images`, "POST");
console.log("[ProviderConfig] probe result:", JSON.stringify(result));
probeResult = result;
if (result.max_images != null) {
addToast(
$t.llm.max_images_detected.replace("{n}", result.max_images),
"success"
);
formData.max_images = result.max_images;
} else {
addToast(
$t.llm.no_max_images_limit,
"info"
);
}
} catch (err) {
probeError = err.message || "Failed to detect max images";
console.warn("[ProviderConfig] Probe failed:", err.message);
addToast(probeError, "error");
} finally {
isProbing = false;
}
}
async function handleSubmit() {
console.log("[ProviderConfig][Action] Submitting provider config");
const method = editingProvider ? "PUT" : "POST";
@@ -480,6 +531,48 @@
{$t.llm.multimodal_checkbox}
</label>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-sm font-medium text-gray-700">
{$t.llm.max_images_label}
</span>
<HelpTooltip text={$t.llm.help_max_images} />
{#if formData.max_images != null}
<span class="ml-1 text-sm font-mono text-gray-600 bg-gray-100 px-2 py-0.5 rounded">
{formData.max_images}
</span>
{:else}
<span class="ml-1 text-xs text-gray-400">
{$t.llm.not_detected}
</span>
{/if}
</div>
<button
type="button"
class="text-xs px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-50"
onclick={detectMaxImages}
disabled={isProbing || !editingProvider}
title={editingProvider ? $t.llm.detect_max_images : $t.llm.save_before_probe}
>
{#if isProbing}
<span class="inline-block w-3 h-3 border-2 border-gray-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{$t.llm.detecting}
{:else}
{$t.llm.detect_max_images}
{/if}
</button>
</div>
{#if probeError}
<div class="mt-1 text-xs text-red-600">{probeError}</div>
{/if}
{#if probeResult && probeResult.max_images != null}
<div class="mt-1 text-xs text-green-600">
{$t.llm.detected_via.replace("{method}", probeResult.method === "parse_error" ? $t.llm.detection_method_error : $t.llm.detection_method_binary)}
</div>
{/if}
</div>
</div>
{#if testStatus.message}

View File

@@ -33,5 +33,16 @@
"models_load_failed": "Failed to load models: {error}",
"select_model": "Select a model...",
"refresh_models": "Refresh models",
"multimodal_checkbox": "Multimodal (supports images)"
"multimodal_checkbox": "Multimodal (supports images)",
"max_images_label": "Max images per request",
"not_detected": "Not detected",
"detect_max_images": "Detect",
"detecting": "Detecting...",
"detected_via": "Detected via: {method}",
"detection_method_error": "error parsing",
"detection_method_binary": "binary search",
"save_before_probe": "Save the provider first to detect max images",
"max_images_detected": "Max images: {n}",
"no_max_images_limit": "No image limit detected",
"help_max_images": "Auto-detect the maximum number of images the LLM provider accepts per request. Some providers (e.g. Nvidia NeMo) limit to 8-10 images."
}

View File

@@ -33,5 +33,16 @@
"models_load_failed": "Не удалось загрузить модели: {error}",
"select_model": "Выберите модель...",
"refresh_models": "Обновить модели",
"multimodal_checkbox": "Мультимодальная (поддержка изображений)"
"multimodal_checkbox": "Мультимодальная (поддержка изображений)",
"max_images_label": "Макс. изображений за запрос",
"not_detected": "Не определен",
"detect_max_images": "Определить",
"detecting": "Определение...",
"detected_via": "Определено через: {method}",
"detection_method_error": "парсинг ошибки",
"detection_method_binary": "бинарный поиск",
"save_before_probe": "Сначала сохраните провайдера",
"max_images_detected": "Макс. изображений: {n}",
"no_max_images_limit": "Лимит изображений не обнаружен",
"help_max_images": "Автоматическое определение максимального количества изображений, которое LLM-провайдер принимает за один запрос. Некоторые провайдеры (например Nvidia NeMo) ограничивают до 8-10 изображений."
}