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 652de471d2
commit 5e5f958eaa
8 changed files with 414 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.logger import logger
from ...dependencies import get_current_user as get_current_active_user
from ...models.llm import ValidationPolicy
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
from ...schemas.auth import User
from ...services.llm_provider import LLMProviderService, is_masked_or_placeholder, mask_api_key
@@ -83,6 +84,7 @@ async def get_providers(
default_model=p.default_model,
is_active=p.is_active,
is_multimodal=bool(p.is_multimodal) if p.is_multimodal is not None else False,
max_images=p.max_images,
)
for p in providers
]
@@ -269,6 +271,7 @@ async def create_provider(
default_model=provider.default_model,
is_active=provider.is_active,
is_multimodal=bool(provider.is_multimodal) if provider.is_multimodal is not None else False,
max_images=provider.max_images,
)
@@ -305,6 +308,7 @@ async def update_provider(
default_model=provider.default_model,
is_active=provider.is_active,
is_multimodal=bool(provider.is_multimodal) if provider.is_multimodal is not None else False,
max_images=provider.max_images,
)
@@ -316,6 +320,7 @@ async def update_provider(
# @PRE User is authenticated and has admin permissions.
# @POST Returns success status.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION CALLS -> [ValidationPolicy]
@router.delete("/providers/{provider_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_provider(
provider_id: str,
@@ -325,6 +330,20 @@ async def delete_provider(
"""
Delete an LLM provider configuration.
"""
# Check if any active validation tasks reference this provider
active_tasks = db.query(ValidationPolicy).filter(
ValidationPolicy.provider_id == provider_id,
ValidationPolicy.is_active == True,
).all()
if active_tasks:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"error": f"Provider is used by {len(active_tasks)} active validation task(s)",
"blocking_tasks": [{"id": t.id, "name": t.name} for t in active_tasks],
},
)
service = LLMProviderService(db)
if not service.delete_provider(provider_id):
raise HTTPException(status_code=404, detail="Provider not found")
@@ -431,4 +450,150 @@ async def test_provider_config(
# #endregion test_provider_config
# #region ProbeMaxImagesResponse [C:1] [TYPE Class]
# @BRIEF Response model for probe-max-images endpoint.
class ProbeMaxImagesResponse(BaseModel):
max_images: int | None
method: str = "binary_search"
# #endregion ProbeMaxImagesResponse
# #region probe_max_images [C:4] [TYPE Function]
# @BRIEF Probe an LLM provider to discover its max images per request limit.
# @PRE Provider exists and has valid API key.
# @POST Returns the detected max_images limit and caches it on the provider.
# @RELATION CALLS -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [EXT:Library:openai]
@router.post("/providers/{provider_id}/probe-max-images", response_model=ProbeMaxImagesResponse)
async def probe_max_images(
provider_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
"""
Probe an LLM provider to discover the maximum number of images
allowed per request. Uses binary search with a minimal 1x1 JPEG
to find the limit without consuming significant tokens.
The result is cached on the provider's max_images field.
"""
from openai import AsyncOpenAI
# Minimal 1x1 white JPEG in base64 (~840 chars, PIL-generated)
PROBE_IMAGE_B64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAFA3PEY8MlBGQUZaVVBfeMiCeG5uePWvuZHI////////////////////////////////////////////////////2wBDAVVaWnhpeOuCguv/////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwC7RRRQB//Z"
service = LLMProviderService(db)
db_provider = service.get_provider(provider_id)
if not db_provider:
raise HTTPException(status_code=404, detail="Provider not found")
api_key = service.get_decrypted_api_key(provider_id)
if not api_key:
raise HTTPException(
status_code=400,
detail="Provider has no valid API key. Decryption failed or key is missing.",
)
model = db_provider.default_model
if not model:
raise HTTPException(status_code=400, detail="Provider has no default model set")
# Build the client
client = AsyncOpenAI(
api_key=api_key,
base_url=db_provider.base_url,
)
def build_content(n_images: int) -> list[dict]:
"""Build a minimal content array with N probe images."""
content: list[dict] = [{"type": "text", "text": "OK"}]
for _ in range(n_images):
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{PROBE_IMAGE_B64}"},
})
return content
async def try_n(n: int):
"""Try sending N images. Returns True if OK, integer limit if parsed from error, or False if unknown failure."""
try:
await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": build_content(n)}],
max_tokens=1,
)
logger.reason(f"[probe_max_images] {n} images: OK", extra={"src": "probe_max_images"})
return True
except Exception as e:
msg = str(e)
logger.reason(
f"[probe_max_images] {n} images: FAILED — {msg[:200]}",
extra={"src": "probe_max_images"},
)
# Try to parse "At most 8 image(s)" from error
import re
match = re.search(r"At most (\d+) image", msg)
if match:
return int(match.group(1))
return False
# Phase 1: Exponential growth to find upper bound
last_ok = 0
first_fail = None
limit_parsed = None
for n in [1, 2, 4, 8, 16, 32, 64]:
result = await try_n(n)
if result is True:
last_ok = n
elif type(result) is int:
# Parsed exact limit from error message
limit_parsed = result
first_fail = n
break
else:
first_fail = n
break
if limit_parsed is not None:
detected_limit = limit_parsed
method = "parse_error"
elif first_fail is None:
# 64 still OK — no practical limit detected
detected_limit = None
method = "no_limit_detected"
elif last_ok == 0:
# Even 1 image failed — provider may not support multimodal
# Set a minimal safe limit (0 = don't use images) but log the issue
logger.warning(
f"[probe_max_images] Even 1 image failed for provider {provider_id}. "
f"Provider may not support multimodal input or credentials are invalid."
)
detected_limit = 0
method = "binary_search"
else:
# Phase 2: Binary search between last_ok and first_fail
lo, hi = last_ok, first_fail
while lo < hi:
mid = (lo + hi + 1) // 2
result = await try_n(mid)
if result is True:
lo = mid
else:
hi = mid - 1
detected_limit = lo
method = "binary_search"
# Store the result
service.set_max_images(provider_id, detected_limit)
return ProbeMaxImagesResponse(
max_images=detected_limit,
method=method,
)
# #endregion probe_max_images
# #endregion LlmRoutes