Compare commits

...

4 Commits

Author SHA1 Message Date
Will Miao 2373edf73c feat(ui): load provider model catalog asynchronously to avoid blocking page render 2026-07-06 10:02:09 +08:00
Will Miao e0e1b804a7 fix(llm): require api_base for custom provider without preset default 2026-07-06 10:02:04 +08:00
Will Miao fecbe8241f fix(agent): use status= instead of status_code in json_response calls 2026-07-06 10:02:00 +08:00
Will Miao 5983eaa1ce refactor(llm): use catalog-based max_tokens, remove JSON retry, reduce Ollama num_ctx
- Parse limit.output from model catalog alongside model IDs
  for per-model max output token limits
- Use catalog lookup in chat_completion_json() to set max_tokens;
  fall back to 4096 for unknown models (e.g. local Ollama)
- Remove the JSON retry (response_format → plain text fallback);
  keep _try_salvage_json as last-resort for truncated responses
- Reduce Ollama num_ctx from 32768 to 8192 (sufficient for
  metadata enrichment, saves VRAM)
- Fix stale test comment referencing removed retry
2026-07-06 09:13:42 +08:00
7 changed files with 184 additions and 87 deletions
+4 -4
View File
@@ -60,21 +60,21 @@ class AgentHandler:
skill_name = request.match_info.get("skill_name", "") skill_name = request.match_info.get("skill_name", "")
if not skill_name: if not skill_name:
return web.json_response( return web.json_response(
{"error": "Skill name is required"}, status_code=400 {"error": "Skill name is required"}, status=400
) )
try: try:
body = await request.json() body = await request.json()
except Exception: except Exception:
return web.json_response( return web.json_response(
{"error": "Invalid JSON body"}, status_code=400 {"error": "Invalid JSON body"}, status=400
) )
model_paths = body.get("model_paths", []) model_paths = body.get("model_paths", [])
if not model_paths or not isinstance(model_paths, list): if not model_paths or not isinstance(model_paths, list):
return web.json_response( return web.json_response(
{"error": "model_paths must be a non-empty array"}, {"error": "model_paths must be a non-empty array"},
status_code=400, status=400,
) )
service = await self._ensure_service() service = await self._ensure_service()
@@ -161,5 +161,5 @@ class AgentHandler:
# TODO: implement cooperative cancellation in AgentService # TODO: implement cooperative cancellation in AgentService
return web.json_response( return web.json_response(
{"status": "acknowledged", "note": "Cancellation not yet implemented"}, {"status": "acknowledged", "note": "Cancellation not yet implemented"},
status_code=200, status=200,
) )
+21 -1
View File
@@ -38,7 +38,12 @@ from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError from ...services.errors import ResourceNotFoundError
from ...services.llm_service import get_provider_model_ids, fetch_ollama_models from ...services.llm_service import (
PROVIDER_PRESETS,
fetch_ollama_models,
get_all_provider_models,
get_provider_model_ids,
)
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata from ...utils.models import BaseModelMetadata
from ...utils.constants import ( from ...utils.constants import (
@@ -1625,6 +1630,20 @@ class SettingsHandler:
def _is_dedicated_example_images_folder(self, folder_path: str) -> bool: def _is_dedicated_example_images_folder(self, folder_path: str) -> bool:
return is_valid_example_images_root(folder_path) return is_valid_example_images_root(folder_path)
async def get_provider_models(self, request: web.Request) -> web.Response:
"""Return the model catalog for all preset providers.
This endpoint is called asynchronously by the settings UI so that
page rendering never blocks on the remote model catalog fetch.
"""
catalog_provider_ids = [p for p in PROVIDER_PRESETS if p != "custom"]
try:
provider_models = await get_all_provider_models(catalog_provider_ids)
return web.json_response({"success": True, "models": provider_models})
except Exception as exc:
logger.warning("Failed to fetch provider models: %s", exc)
return web.json_response({"success": False, "models": {}, "error": str(exc)})
class UsageStatsHandler: class UsageStatsHandler:
def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None: def __init__(self, usage_stats_factory: UsageStatsFactory = UsageStats) -> None:
@@ -3395,6 +3414,7 @@ class MiscHandlerSet:
"get_settings_libraries": self.settings.get_libraries, "get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library, "activate_library": self.settings.activate_library,
"get_llm_models": self.settings.get_llm_models, "get_llm_models": self.settings.get_llm_models,
"get_provider_models": self.settings.get_provider_models,
"update_usage_stats": self.usage_stats.update_usage_stats, "update_usage_stats": self.usage_stats.update_usage_stats,
"get_usage_stats": self.usage_stats.get_usage_stats, "get_usage_stats": self.usage_stats.get_usage_stats,
"update_lora_code": self.lora_code.update_lora_code, "update_lora_code": self.lora_code.update_lora_code,
+7 -4
View File
@@ -154,10 +154,13 @@ class ModelPageView:
) )
self._template_env._i18n_filter_added = True # type: ignore[attr-defined] self._template_env._i18n_filter_added = True # type: ignore[attr-defined]
from ...services.llm_service import PROVIDER_PRESETS, get_all_provider_models from ...services.llm_service import PROVIDER_PRESETS
catalog_provider_ids = [p for p in PROVIDER_PRESETS if p != "custom"] # Provider presets are embedded directly (local, no await needed).
provider_models = await get_all_provider_models(catalog_provider_ids) # Provider model catalogs are fetched asynchronously by the
# frontend via GET /api/lm/llm/provider-models so page rendering
# never blocks on the remote model catalog (which can take up to
# 30s on cold cache).
template_context = { template_context = {
"is_initializing": is_initializing, "is_initializing": is_initializing,
@@ -167,7 +170,7 @@ class ModelPageView:
"t": self._server_i18n.get_translation, "t": self._server_i18n.get_translation,
"version": self._get_app_version(), "version": self._get_app_version(),
"provider_presets_json": json.dumps(PROVIDER_PRESETS), "provider_presets_json": json.dumps(PROVIDER_PRESETS),
"provider_models_json": json.dumps(provider_models), "provider_models_json": "{}",
} }
if not is_initializing: if not is_initializing:
+1
View File
@@ -23,6 +23,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"), RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"), RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"), RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
+92 -72
View File
@@ -27,18 +27,27 @@ _MODEL_CATALOG_URL = "https://models.dev/api.json"
# In-memory cache: maps provider slug -> list of model ID strings. # In-memory cache: maps provider slug -> list of model ID strings.
_catalog_cache: Optional[Dict[str, List[str]]] = None _catalog_cache: Optional[Dict[str, List[str]]] = None
# Per-model max output token limits parsed from the catalog.
# ``{provider_id: {model_id: max_output_tokens}}``.
_model_output_limits: Dict[str, Dict[str, int]] = {}
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30) _CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
async def _load_model_catalog() -> Dict[str, List[str]]: async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog, returning ``{provider_id: [model_id, ...]}``. """Fetch and parse the model catalog.
Returns ``{provider_id: [model_id, ...]}`` and also populates
:data:`_model_output_limits` with per-model ``limit.output`` values
for use by :func:`_get_model_max_output`.
The JSON at ``_MODEL_CATALOG_URL`` is a dict keyed by provider slug; each The JSON at ``_MODEL_CATALOG_URL`` is a dict keyed by provider slug; each
value has a ``models`` sub-dict keyed by model ID. Only the model IDs are value has a ``models`` sub-dict keyed by model ID. The result is cached
kept. The result is cached in memory after the first successful fetch. in memory after the first successful fetch.
Subsequent calls return the cached data immediately. Subsequent calls return the cached data immediately.
""" """
global _catalog_cache global _catalog_cache, _model_output_limits
if _catalog_cache is not None: if _catalog_cache is not None:
return _catalog_cache return _catalog_cache
@@ -58,25 +67,52 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
return _catalog_cache or {} return _catalog_cache or {}
result: Dict[str, List[str]] = {} result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items(): for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict): if not isinstance(provider_info, dict):
continue continue
models_dict = provider_info.get("models") models_dict = provider_info.get("models")
if not isinstance(models_dict, dict): if not isinstance(models_dict, dict):
continue continue
model_ids = [str(mid) for mid in models_dict.keys() if isinstance(mid, str)] model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids: if model_ids:
result[provider_id] = model_ids result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
_catalog_cache = result _catalog_cache = result
_model_output_limits = output_limits
logger.debug( logger.debug(
"Loaded model catalog: %d providers, %d total models", "Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result), len(result),
sum(len(m) for m in result.values()), sum(len(m) for m in result.values()),
len(output_limits),
) )
return result return result
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
"""Return the model's max output token limit from the catalog, or ``None``.
Returns ``None`` when the provider or model is not found in the catalog
(e.g. local Ollama models, custom models, or user-typed model names).
Callers should fall back to a safe default.
"""
return _model_output_limits.get(provider, {}).get(model)
# Short timeout for Ollama's local API # Short timeout for Ollama's local API
_OLLAMA_API_TIMEOUT = aiohttp.ClientTimeout(total=8) _OLLAMA_API_TIMEOUT = aiohttp.ClientTimeout(total=8)
@@ -246,15 +282,17 @@ class LLMService:
def is_configured(self) -> bool: def is_configured(self) -> bool:
"""Return ``True`` when the LLM provider is minimally configured. """Return ``True`` when the LLM provider is minimally configured.
A provider is considered configured when ``llm_model`` is set and A provider is considered configured when ``llm_model`` is set,
an API key is configured for providers that require one (e.g. an API key is configured for providers that require one (e.g.
Ollama does not). Ollama does not), and an API base URL is set for providers that
have no preset default (e.g. ``custom``).
""" """
cfg = self._get_config() cfg = self._get_config()
has_model = bool(cfg["model"]) has_model = bool(cfg["model"])
has_key = bool(cfg["api_key"]) or not self._provider_requires_key(cfg["provider"]) has_key = bool(cfg["api_key"]) or not self._provider_requires_key(cfg["provider"])
return has_model and has_key has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
return has_model and has_key and has_base
def _resolve_api_base(self, provider: str, api_base: str) -> str: def _resolve_api_base(self, provider: str, api_base: str) -> str:
"""Resolve the API base URL for the given provider. """Resolve the API base URL for the given provider.
@@ -278,20 +316,26 @@ class LLMService:
def _ensure_configured(self) -> Dict[str, Any]: def _ensure_configured(self) -> Dict[str, Any]:
"""Validate configuration and return it, or raise. """Validate configuration and return it, or raise.
A provider is considered configured when ``llm_model`` is set and A provider is considered configured when ``llm_model`` is set,
(for non-Ollama) an API key is configured. an API key is configured for providers that require one, and
an API base URL is set for providers without a preset default.
""" """
cfg = self._get_config() cfg = self._get_config()
has_model = bool(cfg["model"]) has_model = bool(cfg["model"])
needs_key = self._provider_requires_key(cfg["provider"]) needs_key = self._provider_requires_key(cfg["provider"])
has_key = bool(cfg["api_key"]) or not needs_key has_key = bool(cfg["api_key"]) or not needs_key
if not (has_model and has_key): has_base = bool(cfg["api_base"]) or bool(_PROVIDER_DEFAULTS.get(cfg["provider"]))
if not (has_model and has_key and has_base):
parts = [] parts = []
if not has_model: if not has_model:
parts.append("No LLM model specified") parts.append("No LLM model specified")
if not has_key and needs_key: if not has_key and needs_key:
parts.append("No LLM API key configured") parts.append("No LLM API key configured")
if not has_base:
parts.append(
f"No API base URL for provider '{cfg['provider']}'"
)
detail = "; ".join(parts) if parts else "LLM provider is not configured" detail = "; ".join(parts) if parts else "LLM provider is not configured"
raise LLMNotConfiguredError( raise LLMNotConfiguredError(
f"{detail}. Configure it in Settings → AI Provider." f"{detail}. Configure it in Settings → AI Provider."
@@ -364,9 +408,11 @@ class LLMService:
"think": False, "think": False,
"options": { "options": {
"temperature": temperature, "temperature": temperature,
# Allow up to 32K context so the model has room to think # 8K context is sufficient for metadata enrichment
# AND produce output without hitting the 4K default limit. # (prompt ~2-5K, output ~0.2-1K tokens). The old 32K
"num_ctx": 32768, # value was excessive for this use case and increased
# Ollama VRAM usage unnecessarily.
"num_ctx": 8192,
}, },
} }
if response_format is not None: if response_format is not None:
@@ -480,11 +526,15 @@ class LLMService:
temperature: float = 0.3, temperature: float = 0.3,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Call the LLM and return parsed JSON. """Call the LLM with ``response_format=json_object`` and return parsed JSON.
Sends ``response_format: {"type": "json_object"}`` when the provider ``max_tokens`` is resolved in this order:
supports it, and parses the response content as JSON. If parsing 1. Explicit caller-supplied ``max_tokens``
fails, retries once with a clarifying system message. 2. Per-model ``limit.output`` from the model catalog
3. A safe default of 4096 (sufficient for metadata enrichment)
If the response content is empty or not valid JSON, attempts
:func:`_try_salvage_json` before raising.
Args: Args:
system_prompt: System-level instructions system_prompt: System-level instructions
@@ -499,7 +549,7 @@ class LLMService:
Raises: Raises:
LLMNotConfiguredError: Provider not configured LLMNotConfiguredError: Provider not configured
LLMRateLimitError: Rate limited LLMRateLimitError: Rate limited
LLMResponseError: JSON parse failure after retry LLMResponseError: Empty response or JSON parse failure
""" """
messages = [ messages = [
@@ -507,10 +557,15 @@ class LLMService:
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
] ]
# First attempt with JSON mode. # Resolve max_tokens: caller override → catalog lookup → safe default
# Use a generous max_tokens so thinking-enabled models (e.g. if max_tokens is None:
# gemma4 via Ollama) have room to reason AND still emit content. cfg = self._get_config()
effective_max = max_tokens or 131072 effective_max = _get_model_max_output(cfg["provider"], cfg["model"])
else:
effective_max = max_tokens
if effective_max is None:
effective_max = 4096
result = await self.chat_completion( result = await self.chat_completion(
messages=messages, messages=messages,
model=model, model=model,
@@ -519,8 +574,15 @@ class LLMService:
max_tokens=effective_max, max_tokens=effective_max,
) )
content = result.get("content", "") or ""
if not content:
raise LLMResponseError(
"LLM returned empty content in json_object mode. "
f"Raw response: {json.dumps(result)[:500]}"
)
try: try:
parsed = json.loads(result["content"]) parsed = json.loads(content)
logger.debug( logger.debug(
"LLM raw content: %s", "LLM raw content: %s",
json.dumps(parsed, ensure_ascii=False)[:2000], json.dumps(parsed, ensure_ascii=False)[:2000],
@@ -529,52 +591,10 @@ class LLMService:
except (json.JSONDecodeError, TypeError) as exc: except (json.JSONDecodeError, TypeError) as exc:
logger.info( logger.info(
"LLM raw response (first 800 chars): %s", "LLM raw response (first 800 chars): %s",
(result.get("content") or "")[:800], content[:800],
)
logger.warning(
"LLM JSON parse failed on first attempt: %s. Retrying.", exc
) )
# Retry WITHOUT response_format — some providers (Ollama with # Last resort: attempt to salvage partial/truncated JSON
# thinking-enabled models like gemma4) may return empty content
# when json_object mode is active. Fall back to a textual
# instruction instead.
previous_content = result.get("content", "") or ""
retry_messages = messages + [
{
"role": "assistant",
"content": previous_content or "(empty response)",
},
{
"role": "user",
"content": (
"The previous response could not be parsed as JSON. "
"Please respond with ONLY a valid JSON object, no "
"markdown fences or extra text."
),
},
]
result = await self.chat_completion(
messages=retry_messages,
model=model,
temperature=0.0, # More deterministic for retry
max_tokens=effective_max,
)
content = result.get("content", "") or ""
if not content:
raise LLMResponseError(
"LLM response could not be parsed as JSON after retry: "
f"Expecting value: line 1 column 1 (char 0)\n"
f"Raw content: {content[:500]}"
)
try:
return json.loads(content)
except (json.JSONDecodeError, TypeError) as parse_err:
# Last resort: attempt to salvage partial JSON (closing unclosed
# brackets/braces, truncating incomplete strings, etc.)
salvaged = _try_salvage_json(content) salvaged = _try_salvage_json(content)
if salvaged is not None: if salvaged is not None:
logger.warning( logger.warning(
@@ -582,10 +602,10 @@ class LLMService:
len(content), len(content),
) )
return salvaged return salvaged
raise LLMResponseError( raise LLMResponseError(
f"LLM response could not be parsed as JSON after retry: {parse_err}\n" f"LLM response could not be parsed as JSON: {content[:200]}"
f"Raw content: {content[:500]}" )
) from parse_err
def _try_salvage_json(raw: str) -> Dict[str, Any] | None: def _try_salvage_json(raw: str) -> Dict[str, Any] | None:
+27
View File
@@ -789,6 +789,27 @@ export class SettingsManager {
} }
} }
async _fetchProviderModelsAsync() {
try {
const resp = await fetch('/api/lm/llm/provider-models');
if (!resp.ok) return;
const data = await resp.json();
if (data.success && data.models) {
this._providerModels = data.models;
// Refresh model combobox if the settings modal is still open.
// Skip when provider is Ollama — it fetches its own live list
// from the local Ollama API and we must not overwrite it.
const llmProviderSelect = document.getElementById('llmProvider');
const provider = llmProviderSelect ? llmProviderSelect.value : 'openai';
if (this._llmModelCombobox && provider !== 'ollama') {
this._llmModelCombobox.updatePresets(this._providerModels[provider] || []);
}
}
} catch (_) {
// Silently ignore — models stay empty until next modal open
}
}
async loadSettingsToUI() { async loadSettingsToUI() {
// Set frontend settings from state // Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent'); const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
@@ -850,6 +871,12 @@ export class SettingsManager {
} }
} }
// If the embedded provider models is empty (server did not block on
// the remote catalog during page render), fetch asynchronously.
if (!this._providerModels || Object.keys(this._providerModels).length === 0) {
this._fetchProviderModelsAsync();
}
const llmProviderSelect = document.getElementById('llmProvider'); const llmProviderSelect = document.getElementById('llmProvider');
if (llmProviderSelect) { if (llmProviderSelect) {
llmProviderSelect.value = state.global.settings.llm_provider || 'openai'; llmProviderSelect.value = state.global.settings.llm_provider || 'openai';
+27 -1
View File
@@ -136,6 +136,32 @@ class TestLLMServiceConfiguration:
with pytest.raises(LLMNotConfiguredError): with pytest.raises(LLMNotConfiguredError):
service._ensure_configured() service._ensure_configured()
def test_not_configured_custom_without_api_base(self):
settings = MockSettings(
llm_enabled=True, llm_provider="custom",
llm_api_key="sk-test", llm_api_base="", llm_model="gpt-4o",
)
service = LLMService(settings)
assert service.is_configured() is False
def test_custom_configured_with_api_base(self):
settings = MockSettings(
llm_enabled=True, llm_provider="custom",
llm_api_key="sk-test",
llm_api_base="https://my.api.com/v1", llm_model="gpt-4o",
)
service = LLMService(settings)
assert service.is_configured() is True
def test_ensure_configured_raises_custom_without_api_base(self):
settings = MockSettings(
llm_enabled=True, llm_provider="custom",
llm_api_key="sk-test", llm_api_base="", llm_model="gpt-4o",
)
service = LLMService(settings)
with pytest.raises(LLMNotConfiguredError, match="API base URL"):
service._ensure_configured()
class TestLLMServiceChatCompletion: class TestLLMServiceChatCompletion:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -219,7 +245,7 @@ class TestLLMServiceChatCompletionJson:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_chat_completion_json_raises_on_non_json(self, llm_service): async def test_chat_completion_json_raises_on_non_json(self, llm_service):
# First attempt: non-JSON; second attempt (retry): also non-JSON # Non-JSON content raises LLMResponseError (salvage also fails)
mock_response = MockResponse( mock_response = MockResponse(
200, 200,
json_data={ json_data={