diff --git a/VERSION b/VERSION index 7875aaed..80d81e4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.74.4 +0.74.5 diff --git a/aiprovider/README.md b/aiprovider/README.md index cb8651fe..6ffd7afb 100644 --- a/aiprovider/README.md +++ b/aiprovider/README.md @@ -4,7 +4,7 @@ 完整使用说明见: -- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md) +- [AI Provider 指南](../docs/technical/zh/agents-aiprovider.md) 当前支持: @@ -15,6 +15,7 @@ - `AI_PROVIDER=ollama` - request adapter: - `AI_PROVIDER_API=openai-completions` + - `AI_PROVIDER_API=openai-responses` - `AI_PROVIDER_API=anthropic-messages` - `AI_PROVIDER_API=ollama-generate` diff --git a/aiprovider/provider_service.py b/aiprovider/provider_service.py index 674a9448..b53c95f1 100644 --- a/aiprovider/provider_service.py +++ b/aiprovider/provider_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import json from typing import Any +from uuid import NAMESPACE_URL, uuid4, uuid5 import httpx from fastapi import HTTPException, status @@ -65,6 +66,7 @@ class ProviderService: self.model_provider_apis = self._parse_model_provider_apis( overrides.get("model_provider_apis") ) + self.session_id = str(uuid4()) def get_status(self) -> AIProviderStatusResponse: enabled = self.provider != "disabled" @@ -95,6 +97,8 @@ class ProviderService: ) prompt = self._build_prompt(payload) + if payload.context.get("session_id") is not None: + self.session_id = str(uuid5(NAMESPACE_URL, f"planet:{payload.context['session_id']}")) provider_api = self._resolve_model_provider_api(model) @@ -102,6 +106,10 @@ class ProviderService: data = await self._request_openai_compatible(model, prompt, payload.system_prompt) content = self._extract_openai_content(data) content_blocks = self._extract_openai_blocks(data) + elif provider_api == "openai-responses": + data = await self._request_openai_responses(model, prompt, payload.system_prompt) + content_blocks = self._extract_responses_blocks(data) + content = "".join(block.text for block in content_blocks if block.text) elif provider_api == "anthropic-messages": data = await self._request_anthropic_messages( model, @@ -200,6 +208,39 @@ class ProviderService: request_body=request_body, ) + async def _request_openai_responses( + self, model: str, prompt: str, system_prompt: str | None = None, + ) -> dict[str, Any]: + request_body: dict[str, Any] = { + "model": model, "input": prompt, "max_output_tokens": self.max_tokens, "store": False, + } + resolved_system_prompt = self._resolve_system_prompt(system_prompt) + if resolved_system_prompt: + request_body["instructions"] = resolved_system_prompt + return await self._post( + path="/responses", + headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, + request_body=request_body, + ) + + def _extract_responses_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]: + blocks: list[AIContentBlock] = [] + for item in payload.get("output") or []: + if not isinstance(item, dict): + continue + if item.get("type") == "message": + for part in item.get("content") or []: + if not isinstance(part, dict): + continue + text = part.get("text") or part.get("refusal") + if isinstance(text, str) and text: + blocks.append(AIContentBlock(type="text", text=text)) + elif item.get("type") == "reasoning": + for part in item.get("summary") or []: + if isinstance(part, dict) and isinstance(part.get("text"), str): + blocks.append(AIContentBlock(type="thinking", thinking=part["text"])) + return blocks + async def _request_anthropic_messages( self, model: str, @@ -226,7 +267,7 @@ class ProviderService: resolved_system_prompt = self._resolve_system_prompt(system_prompt) if resolved_system_prompt: request_body["system"] = resolved_system_prompt - resolved_thinking = self._resolve_anthropic_thinking(thinking) + resolved_thinking = self._resolve_anthropic_thinking(thinking, model) if resolved_thinking: request_body["thinking"] = resolved_thinking if self.provider == "minimax" and self.base_url.endswith("/anthropic"): @@ -243,8 +284,12 @@ class ProviderService: request_body=request_body, ) - def _resolve_anthropic_thinking(self, thinking: dict[str, Any] | None) -> dict[str, Any] | None: + def _resolve_anthropic_thinking( + self, thinking: dict[str, Any] | None, model: str, + ) -> dict[str, Any] | None: if thinking: + if model.casefold() == "minimax-m3" and thinking.get("type") == "enabled": + return {"type": "adaptive"} return thinking # OpenClaw treats MiniMax's Anthropic-compatible path specially: @@ -295,6 +340,9 @@ class ProviderService: headers: dict[str, str], request_body: dict[str, Any], ) -> dict[str, Any]: + headers = {"User-Agent": "Planet/1.0", **headers} + if self.provider == "opencode-go": + headers["x-opencode-session"] = self.session_id last_error: Exception | None = None for attempt in range(1, self.http_retry_attempts + 1): try: diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 8e768508..9efb5139 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -3,9 +3,9 @@ from datetime import UTC, datetime import os from pathlib import Path from typing import Optional +from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -import httpx from pydantic import BaseModel, EmailStr, Field from dotenv import dotenv_values from sqlalchemy import select @@ -65,6 +65,7 @@ from app.services.llm_provider_catalog import ( list_fallback_llm_provider_presets, refresh_llm_provider_preset, ) +from app.services.llm_model_catalog import catalog_error_message, fetch_model_catalog from app.services.scheduler import sync_datasource_job from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings from app.services.persistent_logs import record_audit_log @@ -346,7 +347,13 @@ class ExternalIntegrationsUpdate(BaseModel): def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: - merged = deepcopy(DEFAULT_SETTINGS[category]) + if category.startswith(LLM_PROVIDER_PRESET_CATEGORY_PREFIX): + defaults = get_fallback_llm_provider_preset( + category.removeprefix(LLM_PROVIDER_PRESET_CATEGORY_PREFIX) + ) + else: + defaults = DEFAULT_SETTINGS[category] + merged = deepcopy(defaults) if payload: merged.update(payload) return merged @@ -383,6 +390,7 @@ async def get_setting_payload(db: AsyncSession, category: str) -> dict: async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict: + merged = merge_with_defaults(category, payload) record = await get_setting_record(db, category) if record is None: record = SystemSetting(category=category, payload=payload) @@ -392,7 +400,7 @@ async def save_setting_payload(db: AsyncSession, category: str, payload: dict) - await db.commit() await db.refresh(record) - return merge_with_defaults(category, record.payload) + return merged AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env" @@ -656,6 +664,12 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict: normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider) ) api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config) + model_provider_apis = { + **(provider_config.get("model_provider_apis") or {}), + **(_get_provider_preset(default_provider).get("model_provider_apis") or {}), + } + if default_provider == "openai" and urlsplit(provider_config.get("base_url") or "").hostname != "api.openai.com": + model_provider_apis = {} return { "service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL, "service_token": _resolve_service_token(normalized_ai)[0], @@ -673,8 +687,7 @@ def _runtime_config_from_ai_payload(ai_payload: dict) -> dict: "api_key": api_key, "max_tokens": int(provider_config.get("max_tokens") or 1200), "anthropic_version": provider_config.get("anthropic_version") or "2023-06-01", - "model_provider_apis": provider_config.get("model_provider_apis") or {}, - "preset_models": _get_provider_preset(default_provider).get("models") or [], + "model_provider_apis": model_provider_apis, }, } @@ -754,159 +767,35 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict: } -def _join_provider_url(base_url: str, path: str) -> str: - return f"{base_url.rstrip('/')}/{path.lstrip('/')}" - - -def _extract_model_ids(payload: dict) -> list[str]: - data = payload.get("data") if isinstance(payload, dict) else None - if isinstance(data, list): - return [ - str(item.get("id")) - for item in data - if isinstance(item, dict) and item.get("id") - ] - models = payload.get("models") if isinstance(payload, dict) else None - if isinstance(models, list): - return [ - str(item.get("name") or item.get("model") or item.get("id") or item) - for item in models - if item - ] - return [] - - -def _contains_model(model_ids: list[str], model: str) -> bool: - normalized_model = model.strip().lower() - return any(str(item).strip().lower() == normalized_model for item in model_ids) - - async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) -> dict: provider = _normalize_provider_id(llm_config.get("provider") or "") - configured_api = ( - str(llm_config.get("provider_api") or "").strip() - or ProviderApi.OPENAI_COMPLETIONS.value - ) + provider_api = str(llm_config.get("provider_api") or ProviderApi.OPENAI_COMPLETIONS.value) model = str(llm_config.get("model") or "").strip() - base_url = str(llm_config.get("base_url") or "").strip().rstrip("/") + base_url = str(llm_config.get("base_url") or "").strip() api_key = str(llm_config.get("api_key") or "").strip() - provider_api = configured_api - model_provider_apis = llm_config.get("model_provider_apis") - if isinstance(model_provider_apis, dict): - provider_api = str(model_provider_apis.get(model) or provider_api) - preset_models = [ - str(item) - for item in (llm_config.get("preset_models") or []) - if str(item).strip() - ] - - if not provider or not base_url or not model: - return { - "success": False, - "connected": False, - "message": "当前 provider/base_url/model 未完整配置。", - "mode": "lightweight_config", - } + result = { + "success": False, "connected": False, "mode": "lightweight_models", + "provider": provider, "model": model, + } + if not base_url or not model: + return {**result, "message": "当前 provider/base_url/model 未完整配置。"} if provider_api != ProviderApi.OLLAMA_GENERATE.value and not api_key: - return { - "success": False, - "connected": False, - "message": "当前 provider 未配置 API Key。", - "mode": "lightweight_config", - } - - if provider == "opencode-go": - url = _join_provider_url(base_url, "/models") - headers = {"Authorization": f"Bearer {api_key}"} - elif provider_api == ProviderApi.OLLAMA_GENERATE.value: - url = _join_provider_url(base_url, "/api/tags") - headers: dict[str, str] = {} - elif provider_api == ProviderApi.OPENAI_COMPLETIONS.value: - url = _join_provider_url(base_url, "/models") - headers = {"Authorization": f"Bearer {api_key}"} - elif provider_api == ProviderApi.ANTHROPIC_MESSAGES.value: - url = _join_provider_url(base_url, "/models") - headers = { - "x-api-key": api_key, - "anthropic-version": str(llm_config.get("anthropic_version") or "2023-06-01"), - } - else: - return { - "success": False, - "connected": False, - "message": f"当前 provider_api 不支持轻量连通性测试: {provider_api}", - "mode": "lightweight_unsupported", - } - + return {**result, "message": "当前 provider 未配置 API Key。"} try: - async with httpx.AsyncClient(timeout=min(timeout_seconds, AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS)) as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - payload = response.json() - except httpx.HTTPStatusError as exc: - detail = exc.response.text or exc.response.reason_phrase - if exc.response.status_code == 404 and _contains_model(preset_models, model): - return { - "success": True, - "connected": True, - "message": "轻量连通性测试通过;当前 provider 不提供可用的模型目录,已按内置模型预设确认。", - "mode": "lightweight_preset", - "provider": provider, - "provider_api": provider_api, - "model": model, - "url": url, - } - return { - "success": False, - "connected": False, - "message": f"轻量连通性测试失败: HTTP {exc.response.status_code} {detail}", - "mode": "lightweight_models", - "url": url, - } + catalog = await fetch_model_catalog( + provider, base_url, provider_api, api_key, + str(llm_config.get("anthropic_version") or "2023-06-01"), timeout_seconds, + ) except Exception as exc: - return { - "success": False, - "connected": False, - "message": f"轻量连通性测试失败: {exc}", - "mode": "lightweight_models", - "url": url, - } - - model_ids = _extract_model_ids(payload) - if model_ids and not _contains_model(model_ids, model): - if _contains_model(preset_models, model): - return { - "success": True, - "connected": True, - "message": "轻量连通性测试通过;provider 模型目录未返回当前别名,已按内置模型预设确认。", - "mode": "lightweight_models_with_preset_alias", - "provider": provider, - "provider_api": provider_api, - "model": model, - "models_count": len(model_ids), - "url": url, - } - return { - "success": False, - "connected": False, - "message": f"连接可用,但模型目录中没有当前模型: {model}", - "mode": "lightweight_models", - "provider": provider, - "model": model, - "models_count": len(model_ids), - "url": url, - } - + return {**result, "message": catalog_error_message(exc)} + result.update({"url": catalog.url, "models_count": len(catalog.models)}) + if model.casefold() not in {item.casefold() for item in catalog.models}: + return {**result, "message": f"模型目录查询成功,但当前模型不在目录中:{model}"} + model_apis = llm_config.get("model_provider_apis") or {} return { - "success": True, - "connected": True, - "message": "轻量连通性测试通过", - "mode": "lightweight_models", - "provider": provider, - "provider_api": provider_api, - "model": model, - "models_count": len(model_ids), - "url": url, + **result, "success": True, "connected": True, + "provider_api": model_apis.get(model) or provider_api, + "message": "模型目录查询成功,当前模型已找到;尚未执行生成调用。", } @@ -1690,7 +1579,8 @@ async def connect_ai_provider_integration( ) await emit_business_log( logger, - event="settings.ai_provider.connect.success", + event=("settings.ai_provider.connect.success" if lightweight_result["success"] + else "settings.ai_provider.connect.failed"), message="AI provider connection test completed", category="ai", service="api", @@ -1700,7 +1590,7 @@ async def connect_ai_provider_integration( "provider": payload.provider, "model": payload.model, "configured": True, - "lightweight_status": lightweight_result.get("status"), + "connected": lightweight_result["connected"], }, ) return { @@ -2064,6 +1954,7 @@ async def refresh_ai_provider_preset( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), + payload: AIProviderIntegrationUpdate | None = None, ): try: provider_id = _normalize_provider_id(provider) @@ -2071,16 +1962,25 @@ async def refresh_ai_provider_preset( except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc + current_payload = await get_setting_payload(db, "external_integrations") + if payload is not None: + if _normalize_provider_id(payload.provider) != provider_id: + raise HTTPException(status_code=400, detail="刷新供应商与表单供应商不一致。") + ai_payload = _build_ai_provider_payload(current_payload, payload) + else: + ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {}) + provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id) + api_key, _api_key_source = _resolve_provider_api_key(provider_id, provider_config) + if not api_key and provider_id not in {"ollama", "opencode-go", "openrouter"}: + raise HTTPException( + status_code=400, detail="请先配置当前供应商的 API Key,再刷新模型列表。", + ) try: - api_key = None - if provider_id == "opencode-go": - current_payload = await get_setting_payload(db, "external_integrations") - ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {}) - provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults( - provider_id - ) - api_key, _api_key_source = _resolve_provider_api_key(provider_id, provider_config) - refreshed = await refresh_llm_provider_preset(provider_id, api_key=api_key) + refreshed = await refresh_llm_provider_preset( + provider_id, api_key=api_key, base_url=provider_config.get("base_url"), + provider_api=provider_config.get("provider_api"), + anthropic_version=provider_config.get("anthropic_version") or "2023-06-01", + ) except Exception as exc: logger.warning( "LLM provider catalog refresh failed", @@ -2094,7 +1994,7 @@ async def refresh_ai_provider_preset( ) raise HTTPException( status_code=502, - detail="模型列表刷新失败,已保留上次模型列表。", + detail=f"{catalog_error_message(exc)} 已保留上次模型列表。", ) from exc refreshed["refreshed_at"] = to_iso8601_utc(datetime.now(UTC)) diff --git a/backend/app/core/enums.py b/backend/app/core/enums.py index 4b04ad77..a06e7509 100644 --- a/backend/app/core/enums.py +++ b/backend/app/core/enums.py @@ -138,6 +138,7 @@ class DatasourceRunStatus(StrEnum): class ProviderApi(StrEnum): ANTHROPIC_MESSAGES = "anthropic-messages" OPENAI_COMPLETIONS = "openai-completions" + OPENAI_RESPONSES = "openai-responses" OLLAMA_GENERATE = "ollama-generate" diff --git a/backend/app/services/llm_model_catalog.py b/backend/app/services/llm_model_catalog.py new file mode 100644 index 00000000..233afaff --- /dev/null +++ b/backend/app/services/llm_model_catalog.py @@ -0,0 +1,186 @@ +"""Authenticated model discovery shared by refresh and connection checks.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime +from urllib.parse import urlsplit, urlunsplit + +import httpx + +CATALOG_TIMEOUT_SECONDS = 30 +CATALOG_MAX_PAGES = 100 +CATALOG_PAGE_SIZE = 100 +CATALOG_REQUEST_ATTEMPTS = 2 +CATALOG_RETRY_DELAY_SECONDS = 0.2 +SUPPORTED_PROVIDER_APIS = { + "openai-completions", + "openai-responses", + "anthropic-messages", + "ollama-generate", +} + + +class LLMProviderCatalogError(RuntimeError): + """A safe, user-facing catalog failure with no upstream response or credentials.""" + + +@dataclass(frozen=True) +class ModelCatalog: + url: str + models: list[str] + + +def model_catalog_url(provider: str, base_url: str, provider_api: str) -> str: + parts = urlsplit(base_url.strip()) + if parts.scheme not in {"http", "https"} or not parts.hostname: + raise LLMProviderCatalogError("请填写有效的 HTTP(S) 模型基础地址。") + if parts.username or parts.password or parts.query or parts.fragment: + raise LLMProviderCatalogError("模型基础地址不能包含账号、密码、查询参数或片段。") + if provider_api not in SUPPORTED_PROVIDER_APIS: + raise LLMProviderCatalogError("当前接口协议不支持模型目录查询。") + path = parts.path.rstrip("/") + if provider_api == "ollama-generate": + path = path.removesuffix("/api").removesuffix("/v1") + "/api/tags" + elif provider == "alibaba" and parts.hostname.endswith(".aliyuncs.com"): + path = "/api/v1/models" + else: + if not path or (provider_api == "anthropic-messages" and path.endswith("/anthropic")): + path += "/v1" + path += "/models" + return urlunsplit((parts.scheme, parts.netloc, path, "", "")) + + +def catalog_error_message(exc: Exception) -> str: + if isinstance(exc, LLMProviderCatalogError): + return str(exc) + if isinstance(exc, (httpx.TimeoutException, TimeoutError)): + return "模型目录请求超时,请检查网络后重试。" + if isinstance(exc, httpx.HTTPStatusError): + code = exc.response.status_code + messages = { + 401: "API Key 验证失败,请检查当前供应商的凭证。", + 403: "当前 API Key 无权访问该模型目录,请检查账号权限和服务地域。", + 404: "模型目录接口不存在,请检查基础地址、地域和接口协议。", + 429: "供应商请求限流,请稍后重试。", + } + return messages.get(code, f"供应商模型目录返回 HTTP {code},请稍后重试。") + if isinstance(exc, httpx.RequestError): + return "无法连接模型目录,请检查基础地址和网络。" + return "模型目录响应无效,请稍后重试。" + + +def _model_rows(payload: object) -> tuple[list[dict[str, object]], dict[str, object]]: + if not isinstance(payload, dict): + raise LLMProviderCatalogError("供应商返回了无效的模型目录。") + envelope = payload.get("output", payload) + if not isinstance(envelope, dict): + raise LLMProviderCatalogError("供应商返回了无效的模型目录。") + rows = envelope.get("data", envelope.get("models")) + if not isinstance(rows, list): + raise LLMProviderCatalogError("供应商响应中没有模型列表。") + if any(not isinstance(row, dict) for row in rows): + raise LLMProviderCatalogError("供应商返回了无效的模型条目。") + return rows, envelope + + +def _model_id(row: dict[str, object]) -> str: + value = row.get("id") or row.get("model") or row.get("name") + if not isinstance(value, str) or not value.strip(): + raise LLMProviderCatalogError("供应商返回了缺少 ID 的模型条目。") + return value.strip() + + +def _model_date(row: dict[str, object]) -> float: + value = row.get("created_at") or row.get("published_time") or row.get("created") + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + pass + return 0 + + +async def _get_catalog_page( + client: httpx.AsyncClient, + url: str, + headers: dict[str, str], + params: dict[str, str | int], +) -> object: + for attempt in range(CATALOG_REQUEST_ATTEMPTS): + try: + response = await client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + if attempt or exc.response.status_code not in {502, 503, 504}: + raise + except httpx.TransportError: + if attempt: + raise + await asyncio.sleep(CATALOG_RETRY_DELAY_SECONDS) + raise LLMProviderCatalogError("模型目录请求失败。") + + +async def fetch_model_catalog( + provider: str, + base_url: str, + provider_api: str, + api_key: str = "", + anthropic_version: str = "2023-06-01", + timeout_seconds: int = CATALOG_TIMEOUT_SECONDS, +) -> ModelCatalog: + url = model_catalog_url(provider, base_url, provider_api) + public_catalog = provider in {"opencode-go", "openrouter"} + if not api_key and provider_api != "ollama-generate" and not public_catalog: + raise LLMProviderCatalogError("请先配置当前供应商的 API Key,再刷新模型列表。") + headers = {"User-Agent": "Planet/1.0", "Accept": "application/json"} + if provider_api == "anthropic-messages" and provider not in { + "opencode-go", + "openrouter", + "alibaba", + "moonshotai", + }: + headers.update({"x-api-key": api_key, "anthropic-version": anthropic_version}) + elif api_key: + headers["Authorization"] = f"Bearer {api_key}" + native_dashscope = provider == "alibaba" and urlsplit(url).hostname.endswith(".aliyuncs.com") + params: dict[str, str | int] = {} + if native_dashscope: + params = {"page_no": 1, "page_size": CATALOG_PAGE_SIZE, "capabilities": "TG"} + rows_by_id: dict[str, dict[str, object]] = {} + timeout = max(1, min(timeout_seconds, CATALOG_TIMEOUT_SECONDS)) + # Bound the complete pagination/retry cycle, not just each individual request. + async with asyncio.timeout(timeout), httpx.AsyncClient(timeout=timeout) as client: + for page in range(CATALOG_MAX_PAGES): + payload = await _get_catalog_page(client, url, headers, params) + rows, envelope = _model_rows(payload) + previous_count = len(rows_by_id) + for row in rows: + rows_by_id[_model_id(row)] = row + has_more = envelope.get("has_more") is True + if native_dashscope: + total = envelope.get("total") + if not isinstance(total, int) or total < 0: + raise LLMProviderCatalogError("供应商返回了无效的模型目录分页信息。") + has_more = len(rows_by_id) < total + params["page_no"] = page + 2 + elif has_more: + cursor = envelope.get("last_id") + if not isinstance(cursor, str) or not cursor or cursor == params.get("after_id"): + raise LLMProviderCatalogError("供应商返回了无效的模型目录分页信息。") + params["after_id"] = cursor + if not has_more: + models = sorted( + rows_by_id, key=lambda key: _model_date(rows_by_id[key]), reverse=True + ) + # An empty Ollama catalog is valid: no models have been installed yet. + if not models and provider_api != "ollama-generate": + raise LLMProviderCatalogError("供应商返回了空模型目录,已保留上次模型列表。") + return ModelCatalog(url=url, models=models) + if len(rows_by_id) == previous_count: + raise LLMProviderCatalogError("供应商模型目录分页没有进展,已保留上次模型列表。") + raise LLMProviderCatalogError("供应商模型目录分页超过限制,已保留上次模型列表。") diff --git a/backend/app/services/llm_provider_catalog.py b/backend/app/services/llm_provider_catalog.py index 96cd755c..48e21888 100644 --- a/backend/app/services/llm_provider_catalog.py +++ b/backend/app/services/llm_provider_catalog.py @@ -3,22 +3,30 @@ from __future__ import annotations from typing import Any +from urllib.parse import urlsplit -import httpx - -MODELS_DEV_URL = "https://models.dev/api.json" -OPENCODE_GO_MODELS_URL = "https://opencode.ai/zen/go/v1/models" - - -class LLMProviderCatalogError(RuntimeError): - """The upstream catalog cannot supply a usable model list.""" - +from app.services.llm_model_catalog import fetch_model_catalog OPENCODE_GO_MODEL_PROVIDER_APIS = { + "minimax-m3": "anthropic-messages", + "qwen3.8-max": "anthropic-messages", + "qwen3.8-flash": "anthropic-messages", + "qwen3.7-max": "anthropic-messages", + "qwen3.7-plus": "anthropic-messages", + "qwen3.6-plus": "anthropic-messages", + "grok-4.6": "openai-responses", + "gpt-5.6-luna": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", "minimax-m2.7": "anthropic-messages", "minimax-m2.5": "anthropic-messages", } OPENCODE_GO_FALLBACK_MODELS = [ + "minimax-m3", + "kimi-k3", + "glm-5.3", + "qwen3.8-max", + "gpt-5.6-luna", "minimax-m2.7", "minimax-m2.5", "kimi-k2.6", @@ -34,24 +42,47 @@ OPENCODE_GO_FALLBACK_MODELS = [ ] +OPENAI_MODEL_PROVIDER_APIS = { + model: "openai-responses" + for model in [ + "gpt-6-astra", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.1", + "gpt-5.1-codex", + ] +} + + FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "minimax": { "provider": "minimax", "label": "MiniMax", "provider_api": "anthropic-messages", "base_url": "https://api.minimaxi.com/anthropic", - "model": "MiniMax-M2.7", - "models": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2"], + "model": "MiniMax-M3", + "models": [ + "MiniMax-M3", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", + "MiniMax-M2.1-highspeed", + "MiniMax-M2", + ], "api_key_env": "MINIMAX_API_KEY", "source": "fallback", }, "openai": { "provider": "openai", "label": "OpenAI", - "provider_api": "openai-completions", + "provider_api": "openai-responses", "base_url": "https://api.openai.com/v1", - "model": "gpt-5.1", - "models": ["gpt-5.1", "gpt-5.1-codex", "gpt-4.1", "gpt-4o"], + "model": "gpt-6-astra", + "models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-4.1"], + "model_provider_apis": OPENAI_MODEL_PROVIDER_APIS, "api_key_env": "OPENAI_API_KEY", "source": "fallback", }, @@ -60,8 +91,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "label": "Anthropic", "provider_api": "anthropic-messages", "base_url": "https://api.anthropic.com/v1", - "model": "claude-sonnet-4-6", - "models": ["claude-sonnet-4-6", "claude-opus-4-5", "claude-3-5-haiku-20241022"], + "model": "claude-opus-5", + "models": ["claude-opus-5", "claude-sonnet-4-6"], "api_key_env": "ANTHROPIC_API_KEY", "source": "fallback", }, @@ -70,8 +101,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "label": "DeepSeek", "provider_api": "openai-completions", "base_url": "https://api.deepseek.com/v1", - "model": "deepseek-chat", - "models": ["deepseek-chat", "deepseek-reasoner"], + "model": "deepseek-flash", + "models": ["deepseek-flash", "deepseek-v4-pro"], "api_key_env": "DEEPSEEK_API_KEY", "source": "fallback", }, @@ -80,8 +111,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "label": "Alibaba Qwen / DashScope", "provider_api": "openai-completions", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "model": "qwen3-max", - "models": ["qwen3-max", "qwen3.5-plus", "qwen-max", "qwen-plus"], + "model": "qwen3.8-max", + "models": ["qwen3.8-max", "qwen3-max", "qwen-plus"], "api_key_env": "DASHSCOPE_API_KEY", "source": "fallback", }, @@ -90,8 +121,8 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { "label": "Moonshot AI / Kimi", "provider_api": "openai-completions", "base_url": "https://api.moonshot.ai/v1", - "model": "kimi-k2.5", - "models": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], + "model": "kimi-k3", + "models": ["kimi-k3", "kimi-k2.5"], "api_key_env": "MOONSHOT_API_KEY", "source": "fallback", }, @@ -128,16 +159,6 @@ FALLBACK_LLM_PROVIDER_PRESETS: dict[str, dict[str, Any]] = { }, } -MODELS_DEV_PROVIDER_KEYS = { - "minimax": "minimax", - "openai": "openai", - "anthropic": "anthropic", - "deepseek": "deepseek", - "alibaba": "alibaba", - "moonshotai": "moonshotai", - "openrouter": "openrouter", -} - def list_fallback_llm_provider_presets() -> list[dict[str, Any]]: return [dict(value) for value in FALLBACK_LLM_PROVIDER_PRESETS.values()] @@ -157,76 +178,40 @@ def _opencode_go_model_provider_apis(model_ids: list[str]) -> dict[str, str]: } -async def refresh_llm_provider_preset(provider: str, api_key: str | None = None) -> dict[str, Any]: +async def refresh_llm_provider_preset( + provider: str, + api_key: str | None = None, + *, + base_url: str | None = None, + provider_api: str | None = None, + anthropic_version: str = "2023-06-01", +) -> dict[str, Any]: fallback = get_fallback_llm_provider_preset(provider) - if fallback["provider"] == "opencode-go": - headers = {"User-Agent": "Planet/1.0"} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: - response = await client.get( - OPENCODE_GO_MODELS_URL, - headers=headers, - ) - response.raise_for_status() - payload = response.json() - - data = payload.get("data") if isinstance(payload, dict) else [] - model_ids = [ - str(item.get("id")) - for item in data - if isinstance(item, dict) and item.get("id") - ] - if not model_ids: - raise LLMProviderCatalogError("The provider returned an empty model catalog") - return { - **fallback, - "model": fallback["model"] if fallback["model"] in model_ids else model_ids[0], - "models": model_ids, - "model_provider_apis": _opencode_go_model_provider_apis(model_ids), - "source": OPENCODE_GO_MODELS_URL, - } - - models_dev_key = MODELS_DEV_PROVIDER_KEYS.get(fallback["provider"]) - if not models_dev_key: - raise LLMProviderCatalogError("Live catalog refresh is unavailable for this provider") - - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: - response = await client.get( - MODELS_DEV_URL, - headers={"User-Agent": "Planet/1.0"}, - ) - response.raise_for_status() - catalog = response.json() - - upstream = catalog.get(models_dev_key) if isinstance(catalog, dict) else None - if not isinstance(upstream, dict): - raise LLMProviderCatalogError("The provider is missing from the model catalog") - - upstream_models = upstream.get("models") if isinstance(upstream.get("models"), dict) else {} - # Catalog insertion order is not release order; old entries can appear first. - model_ids = sorted( - ( - model_id - for model_id, model in upstream_models.items() - if model_id and isinstance(model, dict) - ), - key=lambda model_id: (str(upstream_models[model_id].get("release_date") or ""), model_id), - reverse=True, + resolved_base_url = base_url or fallback["base_url"] + resolved_api = provider_api or fallback["provider_api"] + catalog = await fetch_model_catalog( + fallback["provider"], + resolved_base_url, + resolved_api, + api_key or "", + anthropic_version, ) - if not model_ids: - raise LLMProviderCatalogError("The provider returned an empty model catalog") - base_url = upstream.get("api") or fallback["base_url"] - if fallback["provider"] == "deepseek" and base_url == "https://api.deepseek.com": - base_url = "https://api.deepseek.com/v1" - - refreshed = { + model_provider_apis = ( + _opencode_go_model_provider_apis(catalog.models) + if fallback["provider"] == "opencode-go" + else fallback.get("model_provider_apis", {}) + ) + if ( + fallback["provider"] == "openai" + and urlsplit(resolved_base_url).hostname != "api.openai.com" + ): + model_provider_apis = {} + return { **fallback, - "label": upstream.get("name") or fallback["label"], - "base_url": base_url, - "model": model_ids[0], - "models": model_ids, - "api_key_env": (upstream.get("env") or [fallback["api_key_env"]])[0], - "source": MODELS_DEV_URL, + "base_url": resolved_base_url, + "provider_api": resolved_api, + "model": catalog.models[0] if catalog.models else "", + "models": catalog.models, + "model_provider_apis": model_provider_apis, + "source": catalog.url, } - return refreshed diff --git a/backend/app/services/playground_chat_service.py b/backend/app/services/playground_chat_service.py index 41674896..157f70b1 100644 --- a/backend/app/services/playground_chat_service.py +++ b/backend/app/services/playground_chat_service.py @@ -635,6 +635,7 @@ async def _run_assistant_message( constraints=_collect_constraints(payload.constraints), context={ "source": "playground", + "session_id": session_id, "preset": payload.selected_preset_key, "conversation_history": conversation_history, "history_size": len(conversation_history), diff --git a/backend/tests/test_llm_provider_catalog.py b/backend/tests/test_llm_provider_catalog.py index cb80f8f1..7a2ce9ca 100644 --- a/backend/tests/test_llm_provider_catalog.py +++ b/backend/tests/test_llm_provider_catalog.py @@ -1,64 +1,219 @@ +from copy import deepcopy + import httpx import pytest +from app.services import llm_model_catalog as discovery from app.services import llm_provider_catalog as catalog +REAL_CLIENT = httpx.AsyncClient -def mock_catalog(monkeypatch, payload): - client_type = httpx.AsyncClient - transport = httpx.MockTransport(lambda request: httpx.Response(200, json=payload)) + +def mock_http(monkeypatch, handler): + transport = httpx.MockTransport(handler) monkeypatch.setattr( - catalog.httpx, "AsyncClient", lambda **kwargs: client_type(transport=transport, **kwargs) + discovery.httpx, "AsyncClient", lambda **kw: REAL_CLIENT(transport=transport, **kw) ) @pytest.mark.asyncio -async def test_refresh_orders_by_release_date_before_choosing_default(monkeypatch): - mock_catalog( - monkeypatch, - { - "minimax": { - "models": { - "MiniMax-M2": {"release_date": "2025-10-27"}, - "MiniMax-M3": {"release_date": "2026-06-01"}, - "MiniMax-M2.7": {"release_date": "2026-03-18"}, - "undated-model": {}, +@pytest.mark.parametrize( + "provider,path,auth", + [ + ("minimax", "/anthropic/v1/models", "x-api-key"), + ("anthropic", "/v1/models", "x-api-key"), + ("openai", "/v1/models", "authorization"), + ("deepseek", "/v1/models", "authorization"), + ("alibaba", "/api/v1/models", "authorization"), + ("moonshotai", "/v1/models", "authorization"), + ("openrouter", "/api/v1/models", "authorization"), + ("opencode-go", "/zen/go/v1/models", "authorization"), + ("ollama", "/api/tags", "authorization"), + ], +) +async def test_official_catalog_requests(monkeypatch, provider, path, auth): + preset = catalog.get_fallback_llm_provider_preset(provider) + + def handle(request): + assert request.url.path == path + assert request.url.host == httpx.URL(preset["base_url"]).host + assert request.headers[auth] == ("test-key" if auth == "x-api-key" else "Bearer test-key") + if provider == "alibaba": + assert request.url.params["capabilities"] == "TG" + return httpx.Response( + 200, json={"output": {"total": 1, "models": [{"model": "latest"}]}} + ) + if provider == "ollama": + return httpx.Response(200, json={"models": [{"name": "latest:7b"}]}) + return httpx.Response(200, json={"data": [{"id": "latest"}]}) + + mock_http(monkeypatch, handle) + result = await catalog.refresh_llm_provider_preset(provider, api_key="test-key") + assert result["models"] == (["latest:7b"] if provider == "ollama" else ["latest"]) + assert result["base_url"] == preset["base_url"] + assert "test-key" not in str(result) + + +@pytest.mark.parametrize( + "provider,base,api,path", + [ + ( + "minimax", + "https://api.minimax.io/anthropic/v1/", + "anthropic-messages", + "/anthropic/v1/models", + ), + ("anthropic", "https://api.anthropic.com", "anthropic-messages", "/v1/models"), + ( + "alibaba", + "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + "openai-completions", + "/api/v1/models", + ), + ( + "alibaba", + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "openai-completions", + "/api/v1/models", + ), + ("alibaba", "https://custom.test/gateway/v1", "openai-completions", "/gateway/v1/models"), + ("moonshotai", "https://api.moonshot.cn/v1", "openai-completions", "/v1/models"), + ("ollama", "http://localhost:11434/api", "ollama-generate", "/api/tags"), + ], +) +def test_urls_preserve_region_and_gateway(provider, base, api, path): + url = httpx.URL(discovery.model_catalog_url(provider, base, api)) + assert url.host == httpx.URL(base).host + assert url.path == path + + +@pytest.mark.asyncio +async def test_anthropic_pagination_and_release_order(monkeypatch): + seen = [] + + def handle(request): + seen.append(request.url.params.get("after_id")) + if len(seen) == 1: + return httpx.Response( + 200, + json={ + "data": [{"id": "old", "created_at": "2025-01-01T00:00:00Z"}], + "has_more": True, + "last_id": "old", + }, + ) + return httpx.Response( + 200, + json={"data": [{"id": "new", "created_at": "2026-06-01T00:00:00Z"}], "has_more": False}, + ) + + mock_http(monkeypatch, handle) + result = await catalog.refresh_llm_provider_preset("anthropic", "test-key") + assert seen == [None, "old"] + assert result["models"] == ["new", "old"] + + +@pytest.mark.asyncio +async def test_dashscope_pagination(monkeypatch): + def handle(request): + page = int(request.url.params["page_no"]) + return httpx.Response( + 200, + json={ + "output": { + "total": 2, + "models": [ + {"model": f"page-{page}", "published_time": f"2026-06-0{page} 00:00:00"} + ], } - } - }, + }, + ) + + mock_http(monkeypatch, handle) + result = await catalog.refresh_llm_provider_preset("alibaba", "test-key") + assert result["models"] == ["page-2", "page-1"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {}, + {"data": []}, + {"data": [None]}, + {"data": [{}]}, + {"data": [{"id": "same"}], "has_more": True, "last_id": "same"}, + ], +) +async def test_invalid_or_incomplete_catalog_fails(monkeypatch, payload): + mock_http(monkeypatch, lambda request: httpx.Response(200, json=payload)) + with pytest.raises(discovery.LLMProviderCatalogError): + await catalog.refresh_llm_provider_preset("minimax", "test-key") + + +@pytest.mark.asyncio +async def test_all_models_retained_and_defaults_unchanged(monkeypatch): + before = deepcopy(catalog.FALLBACK_LLM_PROVIDER_PRESETS) + rows = [{"id": f"model-{i}", "created": i} for i in range(140)] + mock_http(monkeypatch, lambda request: httpx.Response(200, json={"data": rows})) + result = await catalog.refresh_llm_provider_preset("openai", "test-key") + assert len(result["models"]) == 140 + assert result["models"][0] == "model-139" + assert catalog.FALLBACK_LLM_PROVIDER_PRESETS == before + + +@pytest.mark.asyncio +async def test_empty_ollama_catalog_is_valid(monkeypatch): + mock_http(monkeypatch, lambda request: httpx.Response(200, json={"models": []})) + result = await catalog.refresh_llm_provider_preset("ollama") + assert result["models"] == [] + assert result["model"] == "" + + +@pytest.mark.asyncio +async def test_opencode_documented_protocols(monkeypatch): + models = [ + "minimax-m3", + "qwen3.8-max", + "gpt-5.6-luna", + "grok-4.6", + "muse-spark-1.3-contributor", + "kimi-k3", + ] + mock_http( + monkeypatch, + lambda request: httpx.Response(200, json={"data": [{"id": model} for model in models]}), ) - - refreshed = await catalog.refresh_llm_provider_preset("minimax") - - assert refreshed["model"] == "MiniMax-M3" - assert refreshed["models"] == ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2", "undated-model"] - assert refreshed["source"] == catalog.MODELS_DEV_URL + result = await catalog.refresh_llm_provider_preset("opencode-go") + assert list(result["model_provider_apis"].values()) == [ + "anthropic-messages", + "anthropic-messages", + "openai-responses", + "openai-responses", + "openai-responses", + "openai-completions", + ] @pytest.mark.asyncio -async def test_refresh_does_not_truncate_new_models(monkeypatch): - models = {f"older-{index}": {"release_date": "2025-01-01"} for index in range(85)} - models["latest"] = {"release_date": "2026-06-01"} - mock_catalog(monkeypatch, {"minimax": {"models": models}}) +async def test_retry_transient_error_only(monkeypatch): + calls = [] - refreshed = await catalog.refresh_llm_provider_preset("minimax") + def handle(request): + calls.append(request) + return httpx.Response(503 if len(calls) == 1 else 200, json={"data": [{"id": "latest"}]}) - assert refreshed["model"] == "latest" - assert len(refreshed["models"]) == 86 + mock_http(monkeypatch, handle) + await catalog.refresh_llm_provider_preset("minimax", "test-key") + assert len(calls) == 2 + calls.clear() + def unauthorized(request): + calls.append(request) + return httpx.Response(401, text="secret-upstream-text") -@pytest.mark.asyncio -@pytest.mark.parametrize("payload", [{}, {"minimax": {"models": {}}}, {"minimax": []}]) -async def test_invalid_catalog_fails_instead_of_claiming_fallback_is_fresh(monkeypatch, payload): - mock_catalog(monkeypatch, payload) - - with pytest.raises(catalog.LLMProviderCatalogError): - await catalog.refresh_llm_provider_preset("minimax") - - -@pytest.mark.asyncio -async def test_empty_opencode_catalog_is_a_refresh_failure(monkeypatch): - mock_catalog(monkeypatch, {"data": []}) - - with pytest.raises(catalog.LLMProviderCatalogError): - await catalog.refresh_llm_provider_preset("opencode-go", api_key="test-key") + mock_http(monkeypatch, unauthorized) + with pytest.raises(httpx.HTTPStatusError) as error: + await catalog.refresh_llm_provider_preset("minimax", "test-key") + assert len(calls) == 1 + assert "secret-upstream-text" not in discovery.catalog_error_message(error.value) diff --git a/backend/tests/test_llm_provider_settings.py b/backend/tests/test_llm_provider_settings.py new file mode 100644 index 00000000..46c4d34e --- /dev/null +++ b/backend/tests/test_llm_provider_settings.py @@ -0,0 +1,165 @@ +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from app.api.v1 import settings as api +from app.models.system_setting import SystemSetting +from app.services import llm_model_catalog as discovery +from app.services.llm_provider_catalog import list_fallback_llm_provider_presets + +REAL_CLIENT = httpx.AsyncClient + + +@pytest.fixture +def settings_db(monkeypatch, tmp_path): + # Real SQL reads, commits and reloads; do not mock the settings helpers. + engine = create_engine("sqlite://") + SystemSetting.__table__.create(engine) + session = Session(engine) + db = SimpleNamespace( + execute=AsyncMock(side_effect=session.execute), + add=session.add, + commit=AsyncMock(side_effect=session.commit), + refresh=AsyncMock(side_effect=session.refresh), + ) + monkeypatch.setattr(api, "AI_PROVIDER_ENV_FILE", tmp_path / "missing.env") + monkeypatch.setattr(api, "_resolve_env_secret", lambda *names: ("", "")) + yield db + session.close() + engine.dispose() + + +def use_upstream(monkeypatch, handler): + transport = httpx.MockTransport(handler) + monkeypatch.setattr( + discovery.httpx, "AsyncClient", lambda **kw: REAL_CLIENT(transport=transport, **kw) + ) + + +@pytest.mark.asyncio +async def test_fresh_database_lists_all_builtin_presets(settings_db): + result = await api.get_ai_provider_presets(current_user=None, db=settings_db) + assert len(result["data"]) == 9 + assert all(row["models"] for row in result["data"]) + assert "MiniMax-M3" in result["data"][0]["models"] + + +@pytest.mark.asyncio +async def test_refresh_commits_reloads_and_preserves_runtime_and_draft(settings_db, monkeypatch): + runtime = { + "ai_provider": { + "default_provider": "minimax", + "service_token": "private-service-token", + "providers": { + "minimax": { + "base_url": "https://saved.test/anthropic", + "model": "old", + "api_key": "saved-secret", + } + }, + } + } + await api.save_setting_payload(settings_db, "external_integrations", deepcopy(runtime)) + before = await api.get_setting_payload(settings_db, "external_integrations") + seen = [] + + def handle(request): + seen.append(request) + assert str(request.url) == "https://draft.test/anthropic/v1/models" + assert request.headers["x-api-key"] == "draft-secret" + return httpx.Response(200, json={"data": [{"id": "MiniMax-M3"}, {"id": "old"}]}) + + use_upstream(monkeypatch, handle) + draft = api.AIProviderIntegrationUpdate( + provider="minimax", + base_url="https://draft.test/anthropic", + model="unsaved-model", + api_key="draft-secret", + service_token="unsaved-service-token", + ) + refreshed = await api.refresh_ai_provider_preset("minimax", None, settings_db, draft) + result = await api.get_ai_provider_presets(None, settings_db) + preset = next(row for row in result["data"] if row["provider"] == "minimax") + assert len(seen) == 1 + assert preset["models"] == ["MiniMax-M3", "old"] + assert preset["refreshed_at"] == refreshed["data"]["refreshed_at"] + assert await api.get_setting_payload(settings_db, "external_integrations") == before + assert "secret" not in str(result) + assert "private-service-token" not in str(result) + assert draft.model == "unsaved-model" + stored = await settings_db.execute( + select(SystemSetting).where(SystemSetting.category == "llm_provider_preset:minimax") + ) + assert stored.scalar_one().payload["models"] == ["MiniMax-M3", "old"] + + +@pytest.mark.asyncio +async def test_failed_refresh_preserves_stored_catalog_and_sanitizes_errors( + settings_db, monkeypatch +): + await api.save_setting_payload( + settings_db, "llm_provider_preset:openrouter", {"models": ["saved-model"]} + ) + use_upstream(monkeypatch, lambda request: httpx.Response(401, text="private-upstream-secret")) + commits = settings_db.commit.await_count + with pytest.raises(api.HTTPException) as error: + await api.refresh_ai_provider_preset("openrouter", None, settings_db) + assert error.value.status_code == 502 + assert "private-upstream-secret" not in error.value.detail + assert settings_db.commit.await_count == commits + assert (await api.get_setting_payload(settings_db, "llm_provider_preset:openrouter"))[ + "models" + ] == ["saved-model"] + + +@pytest.mark.asyncio +async def test_missing_key_is_actionable_and_does_not_request_upstream(settings_db, monkeypatch): + def fail_request(request): + pytest.fail("must not query a private catalog without credentials") + + use_upstream(monkeypatch, fail_request) + with pytest.raises(api.HTTPException) as error: + await api.refresh_ai_provider_preset("minimax", None, settings_db) + assert error.value.status_code == 400 + assert "API Key" in error.value.detail + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "preset", list_fallback_llm_provider_presets(), ids=lambda p: p["provider"] +) +async def test_404_never_passes_by_builtin_model_name(monkeypatch, preset): + use_upstream(monkeypatch, lambda request: httpx.Response(404, text="private-upstream-secret")) + result = await api._check_ai_provider_lightweight( + {**preset, "api_key": "invalid-key", "preset_models": preset["models"]}, 5 + ) + assert result["success"] is False + assert result["connected"] is False + assert "private-upstream-secret" not in result["message"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [{}, {"data": []}, {"data": [{"id": "different-model"}]}]) +async def test_invalid_or_missing_models_never_pass(monkeypatch, payload): + use_upstream(monkeypatch, lambda request: httpx.Response(200, json=payload)) + preset = list_fallback_llm_provider_presets()[0] + result = await api._check_ai_provider_lightweight({**preset, "api_key": "test-key"}, 5) + assert result["success"] is False + + +@pytest.mark.asyncio +async def test_new_model_does_not_need_a_builtin_whitelist(monkeypatch): + use_upstream( + monkeypatch, lambda request: httpx.Response(200, json={"data": [{"id": "future-model"}]}) + ) + preset = list_fallback_llm_provider_presets()[0] + result = await api._check_ai_provider_lightweight( + {**preset, "model": "future-model", "api_key": "test-key"}, 5 + ) + assert result["success"] is True + assert result["url"].endswith("/anthropic/v1/models") diff --git a/backend/tests/test_provider_protocols.py b/backend/tests/test_provider_protocols.py new file mode 100644 index 00000000..87c366ad --- /dev/null +++ b/backend/tests/test_provider_protocols.py @@ -0,0 +1,141 @@ +from pathlib import Path +import sys +from unittest.mock import patch + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +with patch( + "pydantic_settings.sources.providers.dotenv.DotEnvSettingsSource._read_env_files", + return_value={}, +): + from aiprovider.provider_service import ProviderService +from aiprovider.schemas import SituationalAnalysisRequest +from app.api.v1.settings import _runtime_config_from_ai_payload + + +def test_custom_openai_gateway_keeps_its_configured_protocol(): + config = _runtime_config_from_ai_payload( + { + "default_provider": "openai", + "providers": { + "openai": { + "base_url": "https://gateway.test/v1", + "provider_api": "openai-completions", + "model": "gpt-6-astra", + }, + }, + } + )["llm_config"] + service = ProviderService({**config, "api_key": "test-key"}) + assert service._resolve_model_provider_api("gpt-6-astra") == "openai-completions" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model,path", + [("minimax-m3", "/messages"), ("qwen3.8-max", "/messages"), ("gpt-5.6-luna", "/responses")], +) +async def test_runtime_model_routes_use_current_protocols(monkeypatch, model, path): + llm = _runtime_config_from_ai_payload( + { + "default_provider": "opencode-go", + "providers": { + "opencode-go": { + "model": model, + "api_key": "test-key", + "model_provider_apis": {model: "openai-completions"}, + } + }, + } + )["llm_config"] + service = ProviderService(llm) + seen = [] + + async def post(**request): + seen.append(request) + assert request["path"] == path + assert request["request_body"]["model"] == model + if path == "/responses": + assert request["request_body"]["store"] is False + assert "max_tokens" not in request["request_body"] + return { + "output": [ + {"type": "reasoning", "summary": [{"text": "reason"}]}, + {"type": "message", "content": [{"type": "output_text", "text": "OK"}]}, + ] + } + return {"content": [{"type": "text", "text": "OK"}]} + + monkeypatch.setattr(service, "_post", post) + result = await service.analyze(SituationalAnalysisRequest(title="test", objective="reply OK")) + assert len(seen) == 1 + assert result.content == "OK" + + +@pytest.mark.asyncio +async def test_minimax_messages_url_accepts_both_documented_base_forms(monkeypatch): + seen = [] + client_type = httpx.AsyncClient + + def handle(request): + seen.append(str(request.url)) + return httpx.Response(200, json={"content": [{"type": "text", "text": "OK"}]}) + + transport = httpx.MockTransport(handle) + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)) + for base in ["https://api.minimaxi.com/anthropic", "https://api.minimaxi.com/anthropic/v1"]: + service = ProviderService( + { + "provider": "minimax", + "provider_api": "anthropic-messages", + "base_url": base, + "api_key": "test-key", + "model": "MiniMax-M3", + } + ) + result = await service.analyze( + SituationalAnalysisRequest(title="test", objective="reply OK") + ) + assert result.content == "OK" + assert seen == ["https://api.minimaxi.com/anthropic/v1/messages"] * 2 + + +@pytest.mark.asyncio +async def test_m3_playground_thinking_and_opencode_session_headers(monkeypatch): + seen = [] + client_type = httpx.AsyncClient + + def handle(request): + import json + + seen.append(request) + assert json.loads(request.content)["thinking"] == {"type": "adaptive"} + assert request.headers["user-agent"] == "Planet/1.0" + return httpx.Response(200, json={"content": [{"type": "text", "text": "OK"}]}) + + transport = httpx.MockTransport(handle) + monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)) + for session_id in ["conversation-one", "conversation-one", "conversation-two"]: + service = ProviderService( + { + "provider": "opencode-go", + "provider_api": "anthropic-messages", + "base_url": "https://opencode.ai/zen/go/v1", + "api_key": "test-key", + "model": "minimax-m3", + } + ) + await service.analyze( + SituationalAnalysisRequest( + title="test", + objective="reply OK", + thinking={"type": "enabled"}, + context={"session_id": session_id}, + ) + ) + session_headers = [request.headers["x-opencode-session"] for request in seen] + assert session_headers[0] == session_headers[1] + assert session_headers[0] != session_headers[2] diff --git a/backend/tests/test_settings_ai_provider.py b/backend/tests/test_settings_ai_provider.py index 982e4056..4d5612c4 100644 --- a/backend/tests/test_settings_ai_provider.py +++ b/backend/tests/test_settings_ai_provider.py @@ -1,7 +1,5 @@ -from copy import deepcopy from types import SimpleNamespace -import httpx import pytest from app.api.v1 import settings as settings_api @@ -26,71 +24,6 @@ from app.api.v1.settings import ( from app.services.llm_provider_catalog import get_fallback_llm_provider_preset -@pytest.mark.asyncio -async def test_refreshed_presets_survive_listing_without_changing_runtime_settings(monkeypatch): - stored = { - "external_integrations": { - "ai_provider": { - "default_provider": "minimax", - "service_token": "internal-token", - "providers": {"minimax": {"model": "custom-model", "api_key": "provider-key"}}, - } - } - } - runtime_before = deepcopy(stored["external_integrations"]) - - async def fake_save(_db, category, payload): - stored[category] = deepcopy(payload) - return stored[category] - - async def fake_get_many(_db, categories): - return {category: deepcopy(stored.get(category, {})) for category in categories} - - async def fake_refresh(provider, api_key=None): - return { - **get_fallback_llm_provider_preset(provider), - "model": "new-model", - "models": ["new-model", "older-model"], - "source": "https://models.dev/api.json", - } - - monkeypatch.setattr(settings_api, "save_setting_payload", fake_save) - monkeypatch.setattr(settings_api, "get_setting_payloads", fake_get_many) - monkeypatch.setattr(settings_api, "refresh_llm_provider_preset", fake_refresh) - user = SimpleNamespace(id=1, role="admin") - - await settings_api.refresh_ai_provider_preset("minimax", user, object()) - await settings_api.refresh_ai_provider_preset("openai", user, object()) - listed = await settings_api.get_ai_provider_presets(user, object()) - presets = {preset["provider"]: preset for preset in listed["data"]} - - assert presets["minimax"]["models"] == ["new-model", "older-model"] - assert presets["openai"]["model"] == "new-model" - assert presets["anthropic"]["source"] == "fallback" - assert presets["minimax"]["refreshed_at"] - assert stored["external_integrations"] == runtime_before - assert "provider-key" not in str(listed) - assert "internal-token" not in str(listed) - - -@pytest.mark.asyncio -async def test_failed_preset_refresh_is_an_error_and_does_not_save(monkeypatch): - async def fail_refresh(*args, **kwargs): - raise httpx.ConnectError("upstream error with secret-value") - - async def fail_save(*args, **kwargs): - pytest.fail("failed refresh must preserve the last saved preset") - - monkeypatch.setattr(settings_api, "refresh_llm_provider_preset", fail_refresh) - monkeypatch.setattr(settings_api, "save_setting_payload", fail_save) - - with pytest.raises(settings_api.HTTPException) as error: - await settings_api.refresh_ai_provider_preset("minimax", SimpleNamespace(id=1), object()) - - assert error.value.status_code == 502 - assert "secret-value" not in error.value.detail - - @pytest.fixture(autouse=True) def isolated_ai_provider_env_file(monkeypatch, tmp_path): env_file = tmp_path / ".env" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6a20de18..6ad701b5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,23 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.74.5] — 2026-09-13 + +Released: 2026-09-13 + +### Highlights +- 恢复模型供应商预设的加载与持久化,模型刷新和轻量连接检查统一使用供应商官方目录接口。 +- 修复 MiniMax M3 思考模式和 OpenCode Go 模型协议路由,补齐 Responses 适配。 + +### Added / Fixed / Improved +- 修复 `llm_provider_preset:*` 动态设置分类触发 KeyError 和列表 500,使用真实数据库读写回归验证保存与重载。 +- 统一九家供应商的目录 URL、鉴权、响应解析与分页,保留服务地域和自定义地址;明确处理限流、空目录和失败,移除 404 白名单误判成功。 +- 模型刷新读取表单草稿地址和凭证,保留默认模型、未保存修改及失败前目录;连接提示区分目录可用与实际生成成功。 +- 增加 Responses 请求与响应解析、M3 adaptive 思考适配和 OpenCode 会话标识;已知模型选择同步协议,自定义 OpenAI 网关保留手动协议。 +- 同步中英文接口说明、操作手册与快速开始,并覆盖模型目录失败、草稿保留和协议适配回归。 + +--- + ## [0.74.4] — 2026-09-13 Released: 2026-09-13 diff --git a/docs/technical/en/agents-aiprovider.md b/docs/technical/en/agents-aiprovider.md index bfcbbd32..a05f8c87 100644 --- a/docs/technical/en/agents-aiprovider.md +++ b/docs/technical/en/agents-aiprovider.md @@ -51,6 +51,7 @@ That split makes MiniMax, Claude-compatible gateways, and self-hosted OpenAI-com Supported request adapters: - `openai-completions` +- `openai-responses` - `anthropic-messages` - `ollama-generate` @@ -102,7 +103,29 @@ The AI settings page uses: These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview. -`backend/app/services/llm_provider_catalog.py` refreshes catalogs from models.dev for most providers and from OpenCode Go's own models endpoint for that provider. Entries from models.dev are sorted by release date, newest first, with undated entries last. Successful results and `refreshed_at` are stored per provider in the `system_settings` category `llm_provider_preset:`. Listing prefers saved catalogs and uses bundled presets for providers that have not been refreshed. Refresh does not write the active model, protocol, URLs, or credentials in `external_integrations`. Failure returns 502 and preserves the previous catalog without exposing raw upstream errors. The frontend reloads catalog state while retaining the form draft. +Model refresh and lightweight checks share `backend/app/services/llm_model_catalog.py` and query provider endpoints directly, without models.dev. Refresh uses the draft base URL, protocol and credentials, or saved configuration when no draft is supplied. Regional hosts and custom gateway paths are preserved. Missing required credentials return 400; upstream failures return safe errors and retain the previous catalog. + +Successful catalogs and `refreshed_at` are stored under `llm_provider_preset:` in `system_settings`. Dynamic categories use the provider preset as their defaults and are validated before committing. Listing prefers saved catalogs and otherwise shows bundled suggestions. Refresh never modifies the active model, protocol, URL or credentials in `external_integrations`. The frontend retains the draft and previously loaded models when listing fails, with an explicit error message. + +### Model Catalog Endpoints + +These endpoints were checked against official documentation on 2026-09-13. The table lists discovery URLs; the form still takes the generation base URL. Credentials must belong to the configured service region. + +| Provider | Model list GET endpoint | Authentication and parsing | +| --- | --- | --- | +| [MiniMax](https://platform.minimax.io/docs/api-reference/models/anthropic/list-models) | `https://api.minimaxi.com/anthropic/v1/models`; international: `api.minimax.io` | `x-api-key`; `data[].id`; `has_more/last_id` pagination | +| [OpenAI](https://developers.openai.com/api/reference/resources/models/methods/list) | `https://api.openai.com/v1/models` | Bearer; `data[].id` | +| [Anthropic](https://platform.claude.com/docs/en/api/models/list) | `https://api.anthropic.com/v1/models` | `x-api-key`, `anthropic-version`; paginate with `after_id` | +| [DeepSeek](https://api-docs.deepseek.com/api/list-models) | `https://api.deepseek.com/v1/models` | Bearer; `data[].id` | +| [Alibaba Model Studio](https://help.aliyun.com/zh/model-studio/list-models) | `/api/v1/models` on the configured regional host | Bearer; `output.models[].model`; `page_no/page_size/output.total`; filter `capabilities=TG` | +| [Moonshot / Kimi](https://platform.kimi.ai/docs/api/list-models) | `https://api.moonshot.ai/v1/models`; China: `api.moonshot.cn` | Bearer; `data[].id` | +| [OpenRouter](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties) | `https://openrouter.ai/api/v1/models` | Public catalog; Bearer when supplied; `data[].id` | +| [OpenCode Go](https://opencode.ai/docs/go/#models) | `https://opencode.ai/zen/go/v1/models` | Public catalog; Bearer when supplied; `data[].id` | +| [Ollama](https://docs.ollama.com/api/tags) | `/api/tags` on the configured server | Local servers need no key; `models[].model/name`; empty means no installed models | + +New Model Studio endpoints for Beijing, Tokyo, Frankfurt and Virginia require the actual workspace host, such as `.cn-beijing.maas.aliyuncs.com`. Singapore uses `dashscope-intl.aliyuncs.com`; Hong Kong uses `cn-hongkong.dashscope.aliyuncs.com`. Discovery changes only the path, never guesses a workspace or switches credential regions. If a legacy Beijing host no longer accepts the account, update the base URL from the provider console. + +Pagination and retries have a 30-second overall deadline. Network failures and 502/503/504 allow up to two attempts; authentication failures are not retried. Models are sorted by supplied creation/release dates, preserving upstream order on ties, without arbitrary list truncation. Public catalog membership does not establish account-specific generation permission. Admin keeps the AI page aligned with the legacy information architecture: @@ -286,22 +309,11 @@ Tool keys follow the same rule. WebSearch and OCR must match the current tool an ### Lightweight Connectivity Testing -The Admin plug button performs a lightweight connectivity check and does not save configuration. Common API-platform practice is two-tiered: - -- Check a provider catalog or low-cost endpoint to validate base URL, authentication, and model reachability. -- Send full model requests only when the user explicitly runs Playground or a business task. - -Connectivity results should be explicit: - -- `ok`: authentication, route, and model catalog are usable. -- `warning`: service is reachable, but the current model is missing from the catalog or capability metadata is incomplete. -- `error`: authentication, network, protocol, or model lookup failed. - -Toast titles must match the result; failures must not be titled as a successful connection. +The Admin plug button checks proxy configuration and queries the current provider catalog. It returns `success=true` only when discovery succeeds and contains the selected model. A 404, authentication error, invalid response or missing model never becomes success based on bundled suggestions. The message distinguishes discovery from generation; Playground or a business task verifies actual generation. Testing never saves the draft or switches the default provider. ### OpenCode Go Routing Model -Subscription channels such as OpenCode Go should not be handled by hard-coded frontend model sets. Prefer provider catalog or backend capability discovery that records per-model capabilities such as `chat_completions`, `anthropic_messages`, `models_endpoint`, and whether a subscription key is required. The frontend should display capabilities; the backend should map provider, base URL, model, and adapter into the real request. +OpenCode Go protocol mappings are owned by the backend catalog: MiniMax M3/M2.7/M2.5 and the verified Qwen3.6/3.7/3.8 models use Anthropic Messages; GPT-5.6 Luna, Grok 4.6 and Muse Spark Contributor use Responses; other supported models use Chat Completions. The official `/models` response currently supplies IDs only, so new models still require checking the documented endpoint table. Selecting a model updates its protocol in the draft, and backend mappings supersede known stale mappings. Responses uses `input`, `max_output_tokens` and `store=false`, and parses text and reasoning summaries. OpenCode calls carry an application User-Agent and a stable Playground conversation identifier. Generic `thinking=enabled` is translated to MiniMax M3’s `adaptive` format. #### Settings Page Behavior diff --git a/docs/technical/en/manual.md b/docs/technical/en/manual.md index 261cb609..f3aba725 100644 --- a/docs/technical/en/manual.md +++ b/docs/technical/en/manual.md @@ -163,10 +163,12 @@ Providers and models accept presets or arbitrary custom IDs. Common fields: - Max Tokens, Anthropic Version: keep defaults if unsure - Timeout / Retry: timeout and retry attempts -The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply. +The plug icon at the end of the Base URL input runs a lightweight connection check: it checks the proxy and model catalog, then confirms the selected model is listed. Use Playground afterward to verify generation access and the model's reply; the catalog check itself does not generate a response. Use the refresh icon at the top right to update the available models. A successful refresh saves the catalog for later visits; catalogs with release dates show newer models first. Refresh preserves the current model, credentials, URLs, and unsaved edits. Select a model and click Save to change the model used by the application. A failed refresh displays an error and keeps the previous catalog. +Refresh uses the base URL, protocol, and API key currently entered in the form, so you can fill new configuration and refresh before saving. Credentialed providers require a key for the matching service region; an empty Ollama catalog means no models are installed. Selecting a model with a known protocol also updates the protocol field. Custom OpenAI gateways retain the manually selected protocol. + ### Tools - **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out diff --git a/docs/technical/en/quickstart.md b/docs/technical/en/quickstart.md index 747e1ca9..5992679c 100644 --- a/docs/technical/en/quickstart.md +++ b/docs/technical/en/quickstart.md @@ -33,7 +33,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col After landing on the `/admin` dashboard, here's a recommended walk-through: 1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first -2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. The refresh icon updates and saves the available model catalog while preserving your form; select a model and click Save to apply it. WebSearch / OCR tools are optional +2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to check the model catalog; verify generation in Playground. The refresh icon updates and saves the available model catalog while preserving your form; select a model and click Save to apply it. WebSearch / OCR tools are optional 3. `/earth-content?section=brand`: maintain the Earth HUD logo and title image in Branding. The Upload button inside each URL field opens a file picker, and image files can also be dropped directly onto the matching field. Save the brand configuration after upload 4. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters 5. `/alerts/system`: verify system alerts look right diff --git a/docs/technical/zh/agents-aiprovider.md b/docs/technical/zh/agents-aiprovider.md index e897193b..8cd9e6c1 100644 --- a/docs/technical/zh/agents-aiprovider.md +++ b/docs/technical/zh/agents-aiprovider.md @@ -51,6 +51,7 @@ 支持的请求适配器: - `openai-completions` +- `openai-responses` - `anthropic-messages` - `ollama-generate` @@ -102,7 +103,29 @@ AI 配置页使用的接口: 这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。 -模型目录刷新由 `backend/app/services/llm_provider_catalog.py` 获取上游目录;多数供应商读取 models.dev,OpenCode Go 使用自己的模型接口。models.dev 条目按发布日期倒序排列,缺少日期的条目排在后面。成功结果和 `refreshed_at` 按供应商保存在 `system_settings` 的 `llm_provider_preset:` 分类中,列表接口优先返回已保存目录,未刷新过的供应商使用内置预设。刷新只更新目录,不写入 `external_integrations` 中的当前模型、协议、地址或凭证。失败返回 502 并保留上次目录;上游异常原文不返回客户端。前端重新加载目录,可选模型读取目录状态,表单草稿独立保留。 +模型目录刷新和轻量连通性测试共用 `backend/app/services/llm_model_catalog.py`,直接请求当前供应商的模型接口,不再依赖 models.dev。使用当前表单草稿中的基础地址、协议和凭证;不传草稿时读取已保存配置。国内、国际和自定义网关地址保持各自的地域与路径。缺少必需凭证时返回 400 并提示配置 API Key;上游超时、鉴权或响应错误返回安全提示,并保留上次目录。 + +成功目录和 `refreshed_at` 按供应商保存到 `system_settings` 的 `llm_provider_preset:` 分类。该动态分类使用对应供应商预设作为默认值,提交前校验分类,避免已提交却返回失败。列表优先返回已保存目录,未刷新过的供应商显示内置建议。刷新不修改 `external_integrations` 的当前模型、协议、地址或凭证;页面保留草稿,目录加载失败时保留已显示模型并明确提示失败。 + +### 模型目录接口 + +以下接口于 2026-09-13 对照官方文档核对。表中的 URL 是模型列表地址,配置表单仍填写生成接口的基础地址。需要凭证的目录必须使用对应服务地域的 API Key。 + +| 供应商 | 模型列表 GET 接口 | 鉴权与解析 | +| --- | --- | --- | +| [MiniMax](https://platform.minimax.io/docs/api-reference/models/anthropic/list-models) | `https://api.minimaxi.com/anthropic/v1/models`;国际站使用 `api.minimax.io` | `x-api-key`;`data[].id`;处理 `has_more/last_id` | +| [OpenAI](https://developers.openai.com/api/reference/resources/models/methods/list) | `https://api.openai.com/v1/models` | Bearer;`data[].id` | +| [Anthropic](https://platform.claude.com/docs/en/api/models/list) | `https://api.anthropic.com/v1/models` | `x-api-key`、`anthropic-version`;按 `after_id` 翻页 | +| [DeepSeek](https://api-docs.deepseek.com/api/list-models) | `https://api.deepseek.com/v1/models` | Bearer;`data[].id` | +| [阿里百炼](https://help.aliyun.com/zh/model-studio/list-models) | 同地域主机的 `/api/v1/models` | Bearer;`output.models[].model`;按 `page_no/page_size/output.total` 翻页,筛选 `capabilities=TG` | +| [Moonshot / Kimi](https://platform.kimi.ai/docs/api/list-models) | `https://api.moonshot.ai/v1/models`;国内站使用 `api.moonshot.cn` | Bearer;`data[].id` | +| [OpenRouter](https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties) | `https://openrouter.ai/api/v1/models` | 支持公共目录;有凭证时附带 Bearer;`data[].id` | +| [OpenCode Go](https://opencode.ai/docs/go/#models) | `https://opencode.ai/zen/go/v1/models` | 支持公共目录;有凭证时附带 Bearer;`data[].id` | +| [Ollama](https://docs.ollama.com/api/tags) | 本机或配置服务器的 `/api/tags` | 本地无需 Key;`models[].model/name`;空目录表示尚未安装模型 | + +百炼的北京、东京、法兰克福、弗吉尼亚新接口要求实际业务空间主机,例如 `.cn-beijing.maas.aliyuncs.com`;新加坡使用 `dashscope-intl.aliyuncs.com`,香港使用 `cn-hongkong.dashscope.aliyuncs.com`。系统只替换目录路径,不猜测业务空间或跨地域切换凭证。旧北京域名若不再接受账号凭证,应按控制台给出的业务空间地址更新基础地址。 + +完整分页和重试受 30 秒总超时保护;网络故障及 502/503/504 最多尝试两次,鉴权失败不重试。按接口提供的创建/发布日期倒序排列;日期相同时保留上游顺序,不截断为固定数量。公共目录只表示供应商公开的模型集合,不代表账号已获得每个模型的生成权限。 Admin 的 AI 页面按业务信息架构组织为: @@ -286,22 +309,11 @@ Admin 的密钥状态必须按 provider / tool 精确判断: ### 轻量连通性测试 -Admin 的插头按钮走轻量连通性测试,不承担保存职责。业界常见做法是分两层: - -- 快速检查 provider 目录或低成本 endpoint,确认 base URL、鉴权和当前模型是否可达。 -- 只有在用户明确运行 Playground 或业务任务时才发完整模型请求。 - -因此,连通性测试应尽量使用低成本请求,并返回明确状态: - -- `ok`: 鉴权、路由和模型目录可用。 -- `warning`: 服务可达,但当前模型不在目录或能力声明不完整。 -- `error`: 鉴权失败、网络失败、协议错误或模型不可用。 - -错误 toast 标题必须和结果一致,不能在失败时显示“连通性正常”。 +Admin 插头按钮检查代理服务配置,再查询当前供应商目录;只有目录请求成功且包含所选模型时才返回 `success=true`。404、鉴权失败、无效响应和缺失模型都不会因命中内置建议而被改判成功。提示明确区分“找到模型”和“实际生成成功”;完整调用由 Playground 或业务任务验证。测试不保存表单或切换默认供应商。 ### OpenCode Go 路由模型 -OpenCode Go 这类订阅通道不要靠前端硬编码模型集合判断协议。推荐在 provider catalog 或后端能力发现中记录每个模型的协议能力,例如 `chat_completions`、`anthropic_messages`、`models_endpoint` 和是否需要订阅 key。前端只展示能力结果;后端负责把 provider、base URL、model 和协议适配映射为实际请求。 +OpenCode Go 的模型协议映射由后端目录集中维护:MiniMax M3/M2.7/M2.5 和已核对的 Qwen3.6/3.7/3.8 模型使用 Anthropic Messages;GPT-5.6 Luna、Grok 4.6 和 Muse Spark Contributor 使用 Responses;其余已支持模型使用 Chat Completions。官方 `/models` 当前只提供模型 ID,新增模型的协议仍需对照官方端点表核对。前端选择模型时同步对应协议,后端覆盖旧的已知错误映射。Responses 请求使用 `input`、`max_output_tokens` 和 `store=false`;回复解析文本及推理摘要。OpenCode 请求携带应用 User-Agent 和会话标识;Playground 同一会话保持标识稳定。MiniMax M3 的通用 `thinking=enabled` 转为官方 `adaptive` 格式。 #### 配置页行为 diff --git a/docs/technical/zh/manual.md b/docs/technical/zh/manual.md index 87eec58e..9e277e63 100644 --- a/docs/technical/zh/manual.md +++ b/docs/technical/zh/manual.md @@ -166,10 +166,12 @@ provider 和模型既可选预设也可直接输入自定义 id/name。常用字 - Max Tokens、Anthropic Version:可保持默认 - Timeout / Retry:超时和重试次数 -Base URL 输入框尾端的插头图标会触发连接测试。测试通过会显示当前模型返回的简短回复。 +Base URL 输入框尾端的插头图标会触发轻量连接测试:检查代理和模型目录,并确认当前模型出现在目录中。通过后可到 Playground 发起实际生成,核对账号权限和模型回复;目录检查本身不执行生成调用。 点击右上角的刷新图标可更新“可选模型”。成功后目录会保存,重新打开页面仍可使用;有发布日期的目录按新到旧排列。刷新保留当前模型、密钥、地址和未保存的修改。点击一个可选模型后,再点“保存”才会改变实际使用的模型。刷新失败时会显示错误并保留上次目录。 +刷新使用当前表单中的基础地址、协议和 API Key,因此可以先填写新配置再刷新,无需提前保存。需要凭证的供应商必须填写对应地域的 Key;Ollama 的空目录表示尚未安装模型。选择已知协议的模型时,协议适配会同步到表单;自定义 OpenAI 网关保留手动选择的协议。 + ### 工具 - **WebSearch**:provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰 diff --git a/docs/technical/zh/quickstart.md b/docs/technical/zh/quickstart.md index a0877698..e20185e0 100644 --- a/docs/technical/zh/quickstart.md +++ b/docs/technical/zh/quickstart.md @@ -33,7 +33,7 @@ 进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台: 1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret -2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。右上角刷新图标会更新并保存可选模型目录,同时保留当前表单;选中新模型后点“保存”生效。WebSearch / OCR 工具可选 +2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头检查模型目录;实际生成在 Playground 验证。右上角刷新图标会更新并保存可选模型目录,同时保留当前表单;选中新模型后点“保存”生效。WebSearch / OCR 工具可选 3. `/earth-content?section=brand`:在“品牌标识”里维护智能星球的 Logo 和标题图;对应地址字段内的“上传”按钮支持选择文件,也支持把图片直接拖到字段上,保存后会应用到智能星球 HUD 4. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”;右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数 5. `/alerts/system`:看系统告警是否正常 diff --git a/docs/version-history.md b/docs/version-history.md index eb0b6f06..4f019f76 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.74.4` +- `dev` 当前开发分支历史推导到:`0.74.5` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.74.5` | improvement | `dev` | `v0.74.5` | 恢复模型预设持久化,统一官方目录刷新与连通性检查,修复 M3 思考模式和 OpenCode 协议路由 | | `0.74.4` | improvement | `dev` | `v0.74.4` | Earth 全量渲染与船舶增量更新优化,直播目录搜索和分页,定位队列状态恢复、模型目录保存及启动提速 | | `0.74.3` | improvement | `dev` | `v0.74.3` | Ubuntu / WSL 初始化自动准备 Docker 及用户权限,修正启动诊断,并在建表前核对数据库端口、实际连接和认证 | | `0.74.2` | bugfix | `dev` | `pending` | 收敛 agent harness 到根规则和 Codex skills,删除旧 Claude command 重复入口,并强化视觉证据路径解析与 OCR fallback 规则 | diff --git a/frontend/package.json b/frontend/package.json index 2c1ae669..087042ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.74.4", + "version": "0.74.5", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/src/admin/pages/PlainResourcePages.tsx b/frontend/src/admin/pages/PlainResourcePages.tsx index 5a913fc6..7b51c97c 100644 --- a/frontend/src/admin/pages/PlainResourcePages.tsx +++ b/frontend/src/admin/pages/PlainResourcePages.tsx @@ -3173,7 +3173,20 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { })() setStates((current) => { const next = current.filter((state) => state.section.key !== result.section.key) - next.push(result) + const previous = current.find((state) => state.section.key === result.section.key) + const endpoints = arrayAt(result.raw, 'endpoints') + const failedPreset = endpoints.find((endpoint) => endpoint.key === 'providerPresets' && endpoint.error) + const previousPresets = endpointData(previous?.raw, 'providerPresets') + if (failedPreset && previousPresets && isObjectRecord(result.raw)) { + const previousRows = dataArray(previous?.raw).filter((row) => row.__endpointKey === 'providerPresets') + next.push(normalizeSectionState(result.section, { + ...result.raw, + endpoints: endpoints.map((endpoint) => endpoint === failedPreset ? { ...endpoint, data: previousPresets } : endpoint), + data: [...dataArray(result.raw), ...previousRows], + })) + } else { + next.push(result) + } return next.sort((a, b) => config.sections.findIndex((item) => item.key === a.section.key) - config.sections.findIndex((item) => item.key === b.section.key)) }) if (section.key === activeSectionKey) { @@ -3651,7 +3664,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { } catch (error) { toast({ title: localizeAdminText(`${label}失败`, locale), - description: actionErrorMessage(error), + description: localizeAdminText(actionErrorMessage(error), locale), tone: 'error', }) } finally { @@ -3747,14 +3760,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { const response = await axios.post(apiPath(path), body) const result = connectionResult(response.data) toast({ - title: `${label}${result.ok ? '连通性正常' : '连通性失败'}`, - description: result.message, + title: localizeAdminText(`${label}${result.ok ? '连通性正常' : '连通性失败'}`, locale), + description: localizeAdminText(result.message, locale), tone: result.ok ? 'success' : 'error', }) } catch (error) { toast({ - title: `${label}连通性失败`, - description: actionErrorMessage(error), + title: localizeAdminText(`${label}连通性失败`, locale), + description: localizeAdminText(actionErrorMessage(error), locale), tone: 'error', }) } finally { @@ -4811,6 +4824,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { const webProviders = objectAt(webSearch, 'providers') const ocr = objectAt(integrations, 'ocr') const providerPresets = providerPresetRows(activeState) + const providerCatalogError = arrayAt(activeState.raw, 'endpoints').some((endpoint) => endpoint.key === 'providerPresets' && endpoint.error) const webPresets = webSearchPresetRows(activeState) let groups: HierarchyGroup[] = [] @@ -4852,6 +4866,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { timeout_seconds: Number(providerConfig.timeout_seconds || (canUseRuntimeConfig ? aiProvider.timeout_seconds : 0) || 60), retry_attempts: Number(providerConfig.retry_attempts || (canUseRuntimeConfig ? aiProvider.retry_attempts : 0) || 2), models: Array.isArray(preset.models) ? preset.models : [], + model_provider_apis: objectAt(preset, 'model_provider_apis'), __fallbackSecrets: providerSecretIsRuntime || (providerRuntimeSecretConfigured && !providerOwnSecretConfigured) ? ['api_key'] : [], __defaultDraft: { service_url: text(aiProvider.service_url, ''), @@ -5053,6 +5068,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { { key: 'provider', label: '供应商' }, { key: 'provider_api', label: '协议适配', type: 'select', options: [ { value: 'openai-completions', label: 'OpenAI Chat Completions' }, + { value: 'openai-responses', label: 'OpenAI Responses' }, { value: 'anthropic-messages', label: 'Anthropic Messages' }, { value: 'ollama-generate', label: 'Ollama Generate' }, ] }, @@ -5281,7 +5297,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{activeSection.key === 'integrations' && activeGroup ? ( - + ) : null} {activeGroup?.key.startsWith('web_search:') ? ( <> @@ -5317,12 +5333,23 @@ function ModuleConsole({ config }: { config: ModuleConfig }) { {activeGroup ? (
+ {activeSection.key === 'integrations' && providerCatalogError ? ( +

{localizeAdminText('模型列表加载失败,请重试;已加载的模型列表会保留。', locale)}

+ ) : null} {activeSection.key === 'integrations' && availableModels.length > 0 ? (
{localizeAdminText('可选模型', locale)} -
{availableModels.map((model) => )}
+
{availableModels.map((model) => )}
) : null} + {activeSection.key === 'integrations' && !providerCatalogError && availableModels.length === 0 ? ( +

{localizeAdminText('当前没有可选模型,请配置供应商并刷新模型列表。', locale)}

+ ) : null}
) : } diff --git a/frontend/src/i18n/legacy-ui.ts b/frontend/src/i18n/legacy-ui.ts index f227ed4a..96cd9cda 100644 --- a/frontend/src/i18n/legacy-ui.ts +++ b/frontend/src/i18n/legacy-ui.ts @@ -1,4 +1,8 @@ export const legacyUiTextEnUS: Record = { + 'OpenAI Chat Completions': 'OpenAI Chat Completions', + 'OpenAI Responses': 'OpenAI Responses', + 'AI Provider连通性正常': 'AI Provider connectivity OK', + 'AI Provider连通性失败': 'AI Provider connectivity failed', '3D 模型': '3D models', 'AI 生成教程': 'AI-generated guide', 'AI 分区': 'AI sections', @@ -78,6 +82,19 @@ export const legacyUiTextEnUS: Record = { '刷新模型列表失败': 'Model list refresh failed', '模型列表已更新,当前模型与未保存配置保持不变。': 'Model list updated. Your current model and unsaved settings are unchanged.', '模型列表刷新失败,已保留上次模型列表。': 'Model catalog refresh failed. The previous model list has been kept.', + '模型列表加载失败,请重试;已加载的模型列表会保留。': 'Model list failed to load. Retry; the previously loaded list is retained.', + '当前没有可选模型,请配置供应商并刷新模型列表。': 'No models are available. Configure the provider and refresh the model list.', + '请先配置当前供应商的 API Key,再刷新模型列表。': 'Configure this provider’s API key before refreshing models.', + '模型目录查询成功,当前模型已找到;尚未执行生成调用。': 'Model catalog is reachable and contains the selected model. Generation has not been tested.', + '模型目录查询成功,但当前模型不在目录中:': 'Model catalog is reachable, but does not contain the selected model: ', + '模型目录请求超时,请检查网络后重试。': 'Model catalog request timed out. Check the network and retry.', + 'API Key 验证失败,请检查当前供应商的凭证。': 'API key authentication failed. Check this provider’s credentials.', + '当前 API Key 无权访问该模型目录,请检查账号权限和服务地域。': 'This API key cannot access the model catalog. Check account permissions and service region.', + '模型目录接口不存在,请检查基础地址、地域和接口协议。': 'Model catalog endpoint was not found. Check the base URL, region, and protocol.', + '供应商请求限流,请稍后重试。': 'The provider rate limit was reached. Retry later.', + '无法连接模型目录,请检查基础地址和网络。': 'Cannot reach the model catalog. Check the base URL and network.', + '模型目录响应无效,请稍后重试。': 'Invalid model catalog response. Retry later.', + '已保留上次模型列表。': 'The previous model list has been kept.', '刷新间隔(秒)': 'Refresh interval (seconds)', '刷新模型': 'Refresh models', '刷新模型列表': 'Refresh model list', diff --git a/pyproject.toml b/pyproject.toml index 006066fa..d4710a99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.74.4" +version = "0.74.5" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/scripts/harness/frontend-smoke.mjs b/scripts/harness/frontend-smoke.mjs index 3bd1482e..96d2cb07 100755 --- a/scripts/harness/frontend-smoke.mjs +++ b/scripts/harness/frontend-smoke.mjs @@ -1232,7 +1232,7 @@ async function runAdminInteractionChecks(browser, failures, consoleErrors) { await page.goto(urlFor('/ai?section=integrations'), { waitUntil: 'domcontentloaded' }) await expectVisibleText(page, 'LLM 基础地址', 'connection test input ai provider') await clickFirstVisible(page.locator('.an-connection-test-input button'), 'connection test input ai provider') - await expectVisibleText(page, 'AI Provider连通性正常', 'connection test input ai provider') + await expectVisibleText(page, 'AI 提供方连通性正常', 'connection test input ai provider') await page.goto(urlFor('/ai?section=tools'), { waitUntil: 'domcontentloaded' }) await clickFirstVisible(page.getByRole('button', { name: /tavily/i }), 'connection test input websearch provider') @@ -1339,14 +1339,19 @@ async function runAIModelCatalogChecks(browser, failures, consoleErrors) { await seedAuthenticatedState(page) let models = ['MiniMax-M2.7'] let failRefresh = false + let failListing = false + let refreshBody = null let integrationWrites = 0 page.on('request', (request) => { if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/settings/integrations')) integrationWrites += 1 }) await page.route('**/settings/integrations/ai-provider/presets**', async (route) => { if (route.request().method() === 'POST') { + refreshBody = route.request().postDataJSON() if (failRefresh) return route.fulfill(jsonResponse({ detail: '模型列表刷新失败,已保留上次模型列表。' }, 502)) models = ['MiniMax-M3', 'MiniMax-M2.7'] + } else if (failListing) { + return route.fulfill(jsonResponse({ detail: 'Catalog unavailable' }, 500)) } const preset = { provider: 'minimax', label: 'MiniMax', model: models[0], models, provider_api: 'anthropic-messages' } await route.fulfill(jsonResponse({ data: route.request().method() === 'POST' ? preset : [preset] })) @@ -1365,6 +1370,8 @@ async function runAIModelCatalogChecks(browser, failures, consoleErrors) { await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click() await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).waitFor({ state: 'visible' }) for (const [label, value] of Object.entries(drafts)) assert.equal(await page.getByLabel(label, { exact: true }).inputValue(), value, `${label} draft was overwritten`) + assert.equal(refreshBody.base_url, drafts['LLM 基础地址'], 'refresh must use the draft endpoint') + assert.equal(refreshBody.api_key, drafts['LLM API Key'], 'refresh must use the draft credential') assert.equal(integrationWrites, 0, 'catalog refresh must not save runtime settings') await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).click() assert.equal(await page.getByLabel('默认模型', { exact: true }).inputValue(), 'MiniMax-M3') @@ -1381,6 +1388,11 @@ async function runAIModelCatalogChecks(browser, failures, consoleErrors) { await expectVisibleText(page, '模型列表刷新失败,已保留上次模型列表。', 'catalog failure') assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1) assert.equal(await page.locator('.an-toast--success').count(), 0, 'failed refresh must not claim success') + failRefresh = false + failListing = true + await page.getByRole('button', { name: /刷新当前.*模型配置/ }).click() + await expectVisibleText(page, '模型列表加载失败,请重试;已加载的模型列表会保留。', 'catalog listing failure') + assert.equal(await page.getByRole('button', { name: 'MiniMax-M3', exact: true }).count(), 1, 'listing failure must preserve models') await checkNoFrameworkOverlay(page, 'AI catalog refresh') await checkNoGlobalHorizontalOverflow(page, 'AI catalog refresh') } catch (error) { diff --git a/uv.lock b/uv.lock index c1045c5d..478869c4 100644 --- a/uv.lock +++ b/uv.lock @@ -757,7 +757,7 @@ wheels = [ [[package]] name = "planet" -version = "0.74.4" +version = "0.74.5" source = { virtual = "." } dependencies = [ { name = "aiofiles" },