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:
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 изображений."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user