From ef3860f94567d33c4854151e5ea91326bc10f5a7 Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Wed, 19 Aug 2026 10:37:00 +0800 Subject: [PATCH 1/6] feat(provider): add Flocks Router test environment --- flocks/provider/catalog.json | 52 +++++++++++++++++++ flocks/provider/provider.py | 5 ++ flocks/provider/sdk/flocks_router.py | 18 +++++++ tests/provider/test_chinese_providers.py | 1 + .../src/components/common/OnboardingModal.tsx | 3 +- webui/src/locales/en-US/common.json | 1 + webui/src/locales/zh-CN/common.json | 1 + 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 flocks/provider/sdk/flocks_router.py diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index 665ef4682..ad89a110f 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -29,6 +29,58 @@ "env_vars": [], "models": {} }, + "flocks-router-test": { + "name": "Flocks Router Test", + "description": "OpenAI-compatible Flocks Router test environment", + "npm": "@ai-sdk/openai-compatible", + "default_base_url": "https://flocks-router-test.threatbook-inc.cn/v1", + "credential_schemas": [ + { + "auth_method": "api_key", + "fields": [ + { + "name": "api_key", + "label": "API Key", + "type": "secret", + "required": true, + "placeholder": "Paste your Flocks Router test API Key" + } + ] + } + ], + "env_vars": [ + "FLOCKS_ROUTER_TEST_API_KEY" + ], + "models": { + "deepseek-v4-flash-0731": { + "name": "deepseek-v4-flash-0731", + "family": "deepseek-v4", + "capabilities": { + "supports_tools": true, + "thinking_level_map": { + "minimal": null, + "low": null, + "medium": null, + "high": "high", + "xhigh": "max", + "max": "max" + }, + "supports_streaming": true + }, + "limits": { + "context_window": 1000000, + "max_input_tokens": 1000000, + "max_output_tokens": 384000 + }, + "pricing": { + "input": 1.0, + "output": 2.0, + "cache_read": 0.2, + "currency": "CNY" + } + } + } + }, "threatbook-cn-llm": { "name": "ThreatBook-cn-llm", "description": "ThreatBook China LLM Service (OpenAI-compatible)", diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index 36e96a8e8..95c4faee7 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -334,6 +334,11 @@ def _ensure_initialized(cls): ("siliconflow", "flocks.provider.sdk.siliconflow", "SiliconFlowProvider"), ("threatbook-cn-llm", "flocks.provider.sdk.threatbook", "ThreatBookCnLLMProvider"), ("threatbook-io-llm", "flocks.provider.sdk.threatbook", "ThreatBookIoLLMProvider"), + ( + "flocks-router-test", + "flocks.provider.sdk.flocks_router", + "FlocksRouterTestLLMProvider", + ), ("ollama", "flocks.provider.sdk.ollama", "OllamaProvider"), # Client-side tool calling (for backends without --enable-auto-tool-choice) ("cherry", "flocks.provider.sdk.cherry", "CherryProvider"), diff --git a/flocks/provider/sdk/flocks_router.py b/flocks/provider/sdk/flocks_router.py new file mode 100644 index 000000000..1e53fc1e4 --- /dev/null +++ b/flocks/provider/sdk/flocks_router.py @@ -0,0 +1,18 @@ +"""Flocks Router OpenAI-compatible provider implementations.""" + +from flocks.provider.sdk.openai_base import OpenAIBaseProvider + + +class FlocksRouterTestLLMProvider(OpenAIBaseProvider): + """LLM provider for the isolated Flocks Router test environment.""" + + DEFAULT_BASE_URL = "https://flocks-router-test.threatbook-inc.cn/v1" + ENV_API_KEY = ["FLOCKS_ROUTER_TEST_API_KEY"] + ENV_BASE_URL = "FLOCKS_ROUTER_TEST_BASE_URL" + CATALOG_ID = "flocks-router-test" + + def __init__(self): + super().__init__( + provider_id="flocks-router-test", + name="Flocks Router Test", + ) diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index 3b3945f2f..ca6d816c7 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -17,6 +17,7 @@ class TestCuratedCatalogProviders: def test_provider_ids_match_curated_list(self): assert set(list_catalog_provider_ids()) == { "openai-compatible", + "flocks-router-test", "threatbook-cn-llm", "threatbook-io-llm", "google", diff --git a/webui/src/components/common/OnboardingModal.tsx b/webui/src/components/common/OnboardingModal.tsx index 8d7b6e0fe..e64bad319 100644 --- a/webui/src/components/common/OnboardingModal.tsx +++ b/webui/src/components/common/OnboardingModal.tsx @@ -262,7 +262,7 @@ export default function OnboardingModal({ onClose }: OnboardingModalProps) { || provider.models.length > 0 ); - const preferredOrder = ['threatbook-cn-llm', 'threatbook-io-llm']; + const preferredOrder = ['threatbook-cn-llm', 'threatbook-io-llm', 'flocks-router-test']; return filtered.sort((a, b) => { const aIndex = preferredOrder.indexOf(a.id); const bIndex = preferredOrder.indexOf(b.id); @@ -355,6 +355,7 @@ export default function OnboardingModal({ onClose }: OnboardingModalProps) { const getProviderLabel = (provider: CatalogProvider) => { if (provider.id === 'threatbook-cn-llm') return t('onboarding.bootstrap.providerThreatBookCn'); if (provider.id === 'threatbook-io-llm') return t('onboarding.bootstrap.providerThreatBookGlobal'); + if (provider.id === 'flocks-router-test') return t('onboarding.bootstrap.providerFlocksRouterTest'); return provider.name; }; diff --git a/webui/src/locales/en-US/common.json b/webui/src/locales/en-US/common.json index 64d34aa41..f144fbcbb 100644 --- a/webui/src/locales/en-US/common.json +++ b/webui/src/locales/en-US/common.json @@ -181,6 +181,7 @@ "primaryThreatBookGlobalLink": "Global users claim API key", "providerThreatBookCn": "ThreatBook-China free model", "providerThreatBookGlobal": "ThreatBook Global free model", + "providerFlocksRouterTest": "Flocks Router Test", "savePrimary": "Save & Verify Model", "saveOptionalThreatBook": "Save & Verify ThreatBook Services", "primaryThreatBookCnHint": "After validation, Flocks will also configure the ThreatBook-China model, API, and MCP services.", diff --git a/webui/src/locales/zh-CN/common.json b/webui/src/locales/zh-CN/common.json index c5fee2597..db76cc615 100644 --- a/webui/src/locales/zh-CN/common.json +++ b/webui/src/locales/zh-CN/common.json @@ -181,6 +181,7 @@ "primaryThreatBookGlobalLink": "国际区用户领取API Key", "providerThreatBookCn": "ThreatBook 中国区免费模型", "providerThreatBookGlobal": "ThreatBook 国际区免费模型", + "providerFlocksRouterTest": "Flocks Router 测试环境", "savePrimary": "保存并验证模型", "saveOptionalThreatBook": "保存并验证微步服务", "primaryThreatBookCnHint": "验证成功后,将同时配置 ThreatBook 中国区模型、API 和 MCP。", From 73abc17f7fbbc0f85a63fd2b5e36e37038e8fdb1 Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Tue, 25 Aug 2026 18:12:39 +0800 Subject: [PATCH 2/6] feat(provider): sync ThreatBook models from Flocks Router --- flocks/provider/catalog.json | 84 ++--- flocks/provider/provider.py | 68 +++- flocks/provider/sdk/flocks_router.py | 18 - flocks/provider/sdk/threatbook.py | 314 +++++++++++++++++- flocks/provider/usage_service.py | 16 +- flocks/server/routes/model.py | 16 + flocks/server/routes/provider.py | 10 + tests/provider/test_chinese_providers.py | 19 +- .../routes/test_provider_model_bootstrap.py | 147 +++++++- .../src/components/common/OnboardingModal.tsx | 3 +- webui/src/locales/en-US/common.json | 1 - webui/src/locales/zh-CN/common.json | 1 - 12 files changed, 590 insertions(+), 107 deletions(-) delete mode 100644 flocks/provider/sdk/flocks_router.py diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index ad89a110f..6e214a060 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -29,58 +29,6 @@ "env_vars": [], "models": {} }, - "flocks-router-test": { - "name": "Flocks Router Test", - "description": "OpenAI-compatible Flocks Router test environment", - "npm": "@ai-sdk/openai-compatible", - "default_base_url": "https://flocks-router-test.threatbook-inc.cn/v1", - "credential_schemas": [ - { - "auth_method": "api_key", - "fields": [ - { - "name": "api_key", - "label": "API Key", - "type": "secret", - "required": true, - "placeholder": "Paste your Flocks Router test API Key" - } - ] - } - ], - "env_vars": [ - "FLOCKS_ROUTER_TEST_API_KEY" - ], - "models": { - "deepseek-v4-flash-0731": { - "name": "deepseek-v4-flash-0731", - "family": "deepseek-v4", - "capabilities": { - "supports_tools": true, - "thinking_level_map": { - "minimal": null, - "low": null, - "medium": null, - "high": "high", - "xhigh": "max", - "max": "max" - }, - "supports_streaming": true - }, - "limits": { - "context_window": 1000000, - "max_input_tokens": 1000000, - "max_output_tokens": 384000 - }, - "pricing": { - "input": 1.0, - "output": 2.0, - "cache_read": 0.2, - "currency": "CNY" - } - } - } - }, "threatbook-cn-llm": { "name": "ThreatBook-cn-llm", "description": "ThreatBook China LLM Service (OpenAI-compatible)", @@ -127,7 +75,6 @@ "pricing": { "input": 1.0, "output": 2.0, - "cache_read": 0.2, "currency": "CNY" } }, @@ -157,7 +104,6 @@ "pricing": { "input": 6.5, "output": 27.0, - "cache_read": 1.3, "currency": "CNY" } }, @@ -237,7 +183,7 @@ }, "pricing": { "input": 2.1, - "output": 8.4, + "output": 8.42, "currency": "CNY" } }, @@ -320,6 +266,33 @@ "currency": "CNY" } }, + "qwen3.8-max": { + "name": "qwen3.8-max", + "family": "qwen3.8", + "capabilities": { + "supports_tools": true, + "supports_vision": true, + "supports_reasoning": true, + "interleaved": { + "field": "reasoning_content", + "echo": "tool_calls", + "cross_provider_policy": "promote" + }, + "thinking_level_map": { + "high": "enabled" + }, + "supports_streaming": true + }, + "limits": { + "context_window": 1000000, + "max_output_tokens": 65536 + }, + "pricing": { + "input": 12.0, + "output": 36.0, + "currency": "CNY" + } + }, "kimi-k2.6": { "name": "kimi-k2.6", "family": "kimi-k2.6", @@ -346,7 +319,6 @@ "pricing": { "input": 6.5, "output": 27.0, - "cache_read": 1.3, "currency": "CNY" } }, diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index 95c4faee7..98403f547 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -334,11 +334,6 @@ def _ensure_initialized(cls): ("siliconflow", "flocks.provider.sdk.siliconflow", "SiliconFlowProvider"), ("threatbook-cn-llm", "flocks.provider.sdk.threatbook", "ThreatBookCnLLMProvider"), ("threatbook-io-llm", "flocks.provider.sdk.threatbook", "ThreatBookIoLLMProvider"), - ( - "flocks-router-test", - "flocks.provider.sdk.flocks_router", - "FlocksRouterTestLLMProvider", - ), ("ollama", "flocks.provider.sdk.ollama", "OllamaProvider"), # Client-side tool calling (for backends without --enable-auto-tool-choice) ("cherry", "flocks.provider.sdk.cherry", "CherryProvider"), @@ -736,6 +731,47 @@ def list_models(cls, provider_id: Optional[str] = None) -> List[ModelInfo]: all_models.extend(provider.get_models()) return all_models + @classmethod + async def refresh_provider_models( + cls, + provider_ids: Optional[List[str]] = None, + ) -> None: + """Refresh provider-owned dynamic model catalogs when supported. + + Most providers keep their model list in ``flocks.json`` and therefore + do not implement ``refresh_models``. Providers backed by an + authoritative remote catalog (currently ThreatBook CN Router) can + expose that coroutine; failures stay isolated so model-list APIs keep + serving their bundled/configured fallback data. + """ + cls._ensure_initialized() + target_ids = ( + list(cls._providers.keys()) + if provider_ids is None + else provider_ids + ) + refreshes = [] + refresh_ids = [] + for pid in target_ids: + provider = cls._providers.get(pid) + refresh = getattr(provider, "refresh_models", None) if provider else None + if callable(refresh): + refresh_ids.append(pid) + refreshes.append(refresh()) + + if not refreshes: + return + + import asyncio + + results = await asyncio.gather(*refreshes, return_exceptions=True) + for pid, result in zip(refresh_ids, results): + if isinstance(result, Exception): + log.warning("provider.models.refresh_failed", { + "provider_id": pid, + "error": str(result), + }) + @classmethod async def apply_config(cls, config: Optional[Any] = None, provider_id: Optional[str] = None) -> None: """ @@ -1366,14 +1402,26 @@ def _apply_config_overrides(self, catalog_def: "ModelDefinition", model: "ModelI return overridden + def _get_model_definition_source_models(self) -> List["ModelInfo"]: + """Return the model list used to build rich definitions. + + Subclasses with an authoritative dynamic catalog can override this + hook without mutating ``_config_models``, which remains the user's + persisted configuration snapshot. + """ + source_models = list(getattr(self, "_config_models", [])) + return source_models if source_models else list(self.get_models()) + def get_model_definitions(self) -> List["ModelDefinition"]: - """Return model definitions for models in flocks.json (_config_models). + """Return rich model definitions for the provider's active model list. If CATALOG_ID is set, catalog.json is used as a metadata source: models whose ID appears in the catalog get the richer catalog entry (parameter_rules, release_date, etc.); all others fall back to the config data in flocks.json. - flocks.json is the single source of truth for *which* models are listed. - User-edited values in flocks.json always override catalog defaults. + By default, flocks.json is the source of truth for *which* models are + listed. Dynamic providers may override + ``_get_model_definition_source_models``. User-edited values in + flocks.json still override catalog defaults where applicable. """ catalog_by_id: dict = {} if self.CATALOG_ID: @@ -1385,9 +1433,7 @@ def get_model_definitions(self) -> List["ModelDefinition"]: except Exception: pass - source_models = list(getattr(self, "_config_models", [])) - if not source_models: - source_models = list(self.get_models()) + source_models = self._get_model_definition_source_models() result = [] for model in source_models: diff --git a/flocks/provider/sdk/flocks_router.py b/flocks/provider/sdk/flocks_router.py deleted file mode 100644 index 1e53fc1e4..000000000 --- a/flocks/provider/sdk/flocks_router.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Flocks Router OpenAI-compatible provider implementations.""" - -from flocks.provider.sdk.openai_base import OpenAIBaseProvider - - -class FlocksRouterTestLLMProvider(OpenAIBaseProvider): - """LLM provider for the isolated Flocks Router test environment.""" - - DEFAULT_BASE_URL = "https://flocks-router-test.threatbook-inc.cn/v1" - ENV_API_KEY = ["FLOCKS_ROUTER_TEST_API_KEY"] - ENV_BASE_URL = "FLOCKS_ROUTER_TEST_BASE_URL" - CATALOG_ID = "flocks-router-test" - - def __init__(self): - super().__init__( - provider_id="flocks-router-test", - name="Flocks Router Test", - ) diff --git a/flocks/provider/sdk/threatbook.py b/flocks/provider/sdk/threatbook.py index 8e346f1ea..c51d2baf2 100644 --- a/flocks/provider/sdk/threatbook.py +++ b/flocks/provider/sdk/threatbook.py @@ -1,12 +1,25 @@ -""" -ThreatBook LLM provider implementations. +"""ThreatBook LLM provider implementations. -ThreatBook provides OpenAI-compatible endpoints for accessing hosted models. -Models are loaded from catalog.json and user-added custom models from -flocks.json by the parent OpenAIBaseProvider.get_models(). +The China service is backed by Flocks Router. Router's ``GET /v1/models`` is +the authority for enabled models and their default (first-tier) prices. The +bundled catalog remains an offline fallback and supplies capability/limit +metadata that Router does not expose. """ -from flocks.provider.sdk.openai_base import OpenAIBaseProvider +import asyncio +import os +import time +from typing import Any, Optional + +import httpx + +from flocks.provider.model_catalog import get_provider_model_definitions +from flocks.provider.provider import ModelCapabilities, ModelInfo +from flocks.provider.sdk.openai_base import ( + OpenAIBaseProvider, + _coerce_bool, + resolve_verify_ssl, +) class ThreatBookCnLLMProvider(OpenAIBaseProvider): @@ -16,9 +29,298 @@ class ThreatBookCnLLMProvider(OpenAIBaseProvider): ENV_API_KEY = ["THREATBOOK_CN_LLM_API_KEY"] ENV_BASE_URL = "THREATBOOK_CN_LLM_BASE_URL" CATALOG_ID = "threatbook-cn-llm" + MODEL_CATALOG_CACHE_TTL_SECONDS = 60.0 + MODEL_CATALOG_TIMEOUT_SECONDS = 5.0 def __init__(self): super().__init__(provider_id="threatbook-cn-llm", name="ThreatBook-cn-llm") + self._router_models: Optional[list[ModelInfo]] = None + self._router_models_url: Optional[str] = None + self._router_models_last_attempt = 0.0 + self._router_models_last_attempt_url: Optional[str] = None + self._router_models_lock = asyncio.Lock() + + def get_models(self) -> list[ModelInfo]: + """Return Router models after the first successful discovery. + + ``None`` and ``[]`` intentionally mean different things: ``None`` has + never been refreshed successfully and falls back to flocks.json; + ``[]`` is a valid authoritative response when Router has no active + models. + """ + if self._router_models is not None: + return list(self._router_models) + if not getattr(self, "_config_models", []): + return [] + # Existing installations can contain obsolete price fields in + # flocks.json. Build the offline fallback from the bundled Router + # snapshot so those stale values cannot override current defaults. + fallback_rows = [] + for model in get_provider_model_definitions(self.CATALOG_ID): + row: dict[str, Any] = {"model_name": model.id} + if model.pricing: + row["input_price"] = model.pricing.input + row["output_price"] = model.pricing.output + fallback_rows.append(row) + return self._build_router_models(fallback_rows) + + @property + def model_catalog_is_authoritative(self) -> bool: + """Router (or its bundled snapshot) owns this provider's model list.""" + return True + + def _get_model_definition_source_models(self) -> list[ModelInfo]: + return self.get_models() + + def _effective_base_url(self) -> str: + configured = self._config.base_url if self._config else None + return (configured or self._base_url or self.DEFAULT_BASE_URL).rstrip("/") + + @staticmethod + def _positive_float_env( + name: str, + default: float, + *, + allow_zero: bool = False, + ) -> float: + try: + value = float(os.getenv(name, default)) + except (TypeError, ValueError): + return default + return value if value > 0 or (allow_zero and value == 0) else default + + @staticmethod + def _price_value(value: Any) -> Optional[float]: + if isinstance(value, bool): + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if parsed >= 0 else None + + @classmethod + def _router_default_prices( + cls, + raw: dict[str, Any], + ) -> tuple[Optional[float], Optional[float]]: + """Resolve the price displayed by Router's fee-details page. + + For tiered models, Router documents ``input_price`` / ``output_price`` + as the first tier. Prefer the explicit first tier defensively so Flocks + still matches the fee page if those fields temporarily drift. + """ + input_price = cls._price_value( + raw.get("input_price", raw.get("inputPrice")) + ) + output_price = cls._price_value( + raw.get("output_price", raw.get("outputPrice")) + ) + tiers = raw.get("price_tiers", raw.get("priceTiers")) + if isinstance(tiers, list) and tiers and isinstance(tiers[0], dict): + first = tiers[0] + tier_input = cls._price_value( + first.get("input_price", first.get("inputPrice")) + ) + tier_output = cls._price_value( + first.get("output_price", first.get("outputPrice")) + ) + if tier_input is not None: + input_price = tier_input + if tier_output is not None: + output_price = tier_output + return input_price, output_price + + def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: + catalog_by_lower = { + model.id.lower(): model + for model in get_provider_model_definitions(self.CATALOG_ID) + } + configured_by_lower = { + model.id.lower(): model + for model in getattr(self, "_config_models", []) + } + result: list[ModelInfo] = [] + seen: set[str] = set() + + for raw in rows: + if not isinstance(raw, dict): + continue + router_name = raw.get("model_name", raw.get("modelName")) + if not isinstance(router_name, str) or not router_name.strip(): + continue + router_name = router_name.strip() + normalized = router_name.lower() + if normalized in seen: + continue + seen.add(normalized) + + catalog = catalog_by_lower.get(normalized) + configured = configured_by_lower.get(normalized) + model_id = catalog.id if catalog else (configured.id if configured else router_name) + + if catalog: + capabilities = ModelCapabilities( + supports_streaming=catalog.capabilities.supports_streaming, + supports_tools=catalog.capabilities.supports_tools, + supports_vision=catalog.capabilities.supports_vision, + supports_reasoning=catalog.capabilities.supports_reasoning, + interleaved=catalog.capabilities.interleaved, + thinking_level_map=catalog.capabilities.thinking_level_map, + max_tokens=catalog.limits.max_output_tokens, + context_window=catalog.limits.context_window, + ) + display_name = catalog.name + else: + capabilities = ModelCapabilities() + display_name = router_name + + explicit_keys = set( + getattr(configured, "_explicit_keys", set()) if configured else set() + ) + if configured: + configured_capabilities = configured.capabilities + capability_fields = { + "supports_streaming": "supports_streaming", + "supports_tools": "supports_tools", + "supports_vision": "supports_vision", + "supports_reasoning": "supports_reasoning", + "interleaved": "interleaved", + "thinking_level_map": "thinking_level_map", + "max_output_tokens": "max_tokens", + "max_tokens": "max_tokens", + "context_window": "context_window", + } + for config_key, attribute in capability_fields.items(): + if config_key in explicit_keys: + setattr( + capabilities, + attribute, + getattr(configured_capabilities, attribute), + ) + if "name" in explicit_keys: + display_name = configured.name + + input_price, output_price = self._router_default_prices(raw) + pricing = None + if input_price is not None and output_price is not None: + pricing = { + "input": input_price, + "output": output_price, + "currency": "CNY", + } + elif catalog and catalog.pricing: + pricing = { + "input": catalog.pricing.input, + "output": catalog.pricing.output, + "currency": catalog.pricing.currency, + } + + model = ModelInfo( + id=model_id, + name=display_name, + provider_id=self.id, + capabilities=capabilities, + pricing=pricing, + custom_settings=( + dict(configured.custom_settings) if configured else {} + ), + ) + model._explicit_keys = explicit_keys + result.append(model) + + return result + + async def refresh_models(self, force: bool = False) -> bool: + """Refresh active models and default prices from Flocks Router. + + The refresh is rate-limited and failure-safe. A failed request never + clears the last successful Router snapshot or the bundled fallback. + """ + base_url = self._effective_base_url() + models_url = f"{base_url}/models" + now = time.monotonic() + ttl = self._positive_float_env( + "THREATBOOK_CN_LLM_MODEL_CACHE_TTL_SECONDS", + self.MODEL_CATALOG_CACHE_TTL_SECONDS, + allow_zero=True, + ) + if ( + not force + and models_url == self._router_models_last_attempt_url + and now - self._router_models_last_attempt < ttl + ): + return self._router_models is not None + + async with self._router_models_lock: + now = time.monotonic() + if ( + not force + and models_url == self._router_models_last_attempt_url + and now - self._router_models_last_attempt < ttl + ): + return self._router_models is not None + + if self._router_models_url and models_url != self._router_models_url: + # Never carry an authoritative snapshot across environments. + # If the new Router is unavailable, fall back to the bundled + # catalog/config rather than showing models from the old URL. + self._router_models = None + self._router_models_url = None + + self._router_models_last_attempt = now + self._router_models_last_attempt_url = models_url + custom_settings = getattr(self._config, "custom_settings", None) or {} + verify_ssl = resolve_verify_ssl(custom_settings, default=True) + trust_env = _coerce_bool(os.getenv("FLOCKS_HTTP_TRUST_ENV"), True) + if isinstance(custom_settings, dict) and "trust_env" in custom_settings: + trust_env = _coerce_bool(custom_settings.get("trust_env"), trust_env) + timeout_seconds = self._positive_float_env( + "THREATBOOK_CN_LLM_MODEL_TIMEOUT_SECONDS", + self.MODEL_CATALOG_TIMEOUT_SECONDS, + ) + headers: dict[str, str] = {"Accept": "application/json"} + api_key = self._config.api_key if self._config else self._api_key + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + async with httpx.AsyncClient( + timeout=timeout_seconds, + verify=verify_ssl, + trust_env=trust_env, + ) as client: + response = await client.get(models_url, headers=headers) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Router model response is not an object") + if "code" in payload and payload.get("code") != 0: + raise ValueError( + f"Router model response failed with code {payload.get('code')}" + ) + rows = payload.get("data") + if not isinstance(rows, list): + raise ValueError("Router model response data is not a list") + models = self._build_router_models(rows) + if rows and not models: + raise ValueError("Router model response has no valid model entries") + except Exception as exc: + self.log.warning("router.models.refresh_failed", { + "url": models_url, + "error_type": type(exc).__name__, + "error": str(exc), + "using_fallback": self._router_models is None, + }) + return False + + self._router_models = models + self._router_models_url = models_url + self.log.info("router.models.refreshed", { + "url": models_url, + "count": len(models), + }) + return True class ThreatBookIoLLMProvider(OpenAIBaseProvider): diff --git a/flocks/provider/usage_service.py b/flocks/provider/usage_service.py index 6679db64a..912259960 100644 --- a/flocks/provider/usage_service.py +++ b/flocks/provider/usage_service.py @@ -124,16 +124,24 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf """Resolve runtime pricing for a provider/model pair.""" model_info = None provider = Provider.get(provider_id) - if provider: + if ( + provider + and getattr(provider, "model_catalog_is_authoritative", False) is True + ): + model_info = Provider.resolve_model(provider_id, model_id) + elif provider: for candidate in getattr(provider, "_config_models", []): if candidate.id == model_id: model_info = candidate break - if model_info is None: - model_info = Provider.get_model(model_id) - pricing = getattr(model_info, "pricing", None) if model_info else None + if pricing is None: + # Config snapshots commonly contain only a model name. Resolve the + # catalog-enriched/provider-owned definition so bundled or dynamically + # refreshed Router prices are still used for usage accounting. + model_info = Provider.resolve_model(provider_id, model_id) + pricing = getattr(model_info, "pricing", None) if model_info else None if pricing is None: return None diff --git a/flocks/server/routes/model.py b/flocks/server/routes/model.py index e4f0bff80..e7cda1c32 100644 --- a/flocks/server/routes/model.py +++ b/flocks/server/routes/model.py @@ -32,6 +32,17 @@ def _connected_provider_ids() -> set[str]: return set(ConfigWriter.list_provider_ids()) +async def _refresh_connected_provider(provider_id: str) -> None: + """Apply config and refresh a connected provider's dynamic catalog.""" + if provider_id not in _connected_provider_ids(): + return + from flocks.config.config import Config + + config = await Config.get() + await Provider.apply_config(config, provider_id=provider_id) + await Provider.refresh_provider_models([provider_id]) + + # ==================== Response Models ==================== class ModelCapabilities(BaseModel): @@ -422,6 +433,9 @@ async def list_model_definitions( try: config = await Config.get() await Provider.apply_config(config, provider_id=provider) + connected = _connected_provider_ids() + refresh_ids = [provider] if provider and provider in connected else list(connected) + await Provider.refresh_provider_models(refresh_ids) except Exception: pass @@ -453,6 +467,7 @@ async def list_model_definitions( ) async def get_parameter_rules(provider_id: str, model_id: str): """Get parameter rules for a model.""" + await _refresh_connected_provider(provider_id) manager = get_model_manager() definition = manager.get_model(provider_id, model_id) if not definition: @@ -473,6 +488,7 @@ async def get_model_definition( provider_id: str, model_id: str ) -> ModelDefinition: """Get a single model definition.""" + await _refresh_connected_provider(provider_id) manager = get_model_manager() definition = manager.get_model(provider_id, model_id) if not definition: diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 4f28570cf..641703e18 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -439,6 +439,13 @@ def _merge_config_models( provider_id: str, config: Any, ) -> Dict[str, Dict[str, Any]]: + provider = Provider.get(provider_id) + if getattr(provider, "model_catalog_is_authoritative", False) is True: + # The provider has already merged matching user overrides into its + # live Router snapshot (or bundled offline snapshot). Re-adding every + # persisted model here would resurrect removed models and stale prices. + return models_dict + provider_cfg = (getattr(config, "provider", None) or {}).get(provider_id) if not provider_cfg or not getattr(provider_cfg, "models", None): return models_dict @@ -536,6 +543,7 @@ async def list_providers() -> ProviderListResponse: try: config = await Config.get() await Provider.apply_config(config) + await Provider.refresh_provider_models(ConfigWriter.list_provider_ids()) disabled = set(config.disabled_providers or []) enabled_set = set(config.enabled_providers) if config.enabled_providers else None except Exception: @@ -826,6 +834,7 @@ async def get_provider(provider_id: str) -> ProviderInfo: config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) + await Provider.refresh_provider_models([provider_id]) # Credentials are resolved at config load time via {secret:xxx} in flocks.json. # Provider.apply_config() above already configures from resolved config. @@ -877,6 +886,7 @@ async def list_models(provider_id: str) -> List[Dict[str, Any]]: return [] config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) + await Provider.refresh_provider_models([provider_id]) provider_models = Provider.list_models(provider_id) models_dict: Dict[str, Dict[str, Any]] = {} diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index ca6d816c7..b57392497 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -17,7 +17,6 @@ class TestCuratedCatalogProviders: def test_provider_ids_match_curated_list(self): assert set(list_catalog_provider_ids()) == { "openai-compatible", - "flocks-router-test", "threatbook-cn-llm", "threatbook-io-llm", "google", @@ -339,6 +338,7 @@ def test_threatbook_cn_llm_catalog(self): "GLM-5", "qwen3.6-plus", "qwen3-max", + "qwen3.8-max", "kimi-k2.6", "deepseek-v4-flash", "deepseek-v4-flash-0731", @@ -350,7 +350,7 @@ def test_threatbook_cn_llm_catalog(self): assert kimi_code.capabilities.supports_reasoning is True assert kimi_code.capabilities.interleaved["field"] == "reasoning_content" assert kimi_code.pricing.currency == "CNY" - assert kimi_code.pricing.cache_read == 1.3 + assert kimi_code.pricing.cache_read is None assert kimi_code.pricing.input == 6.5 assert kimi_code.pricing.output == 27.0 assert kimi_code.limits.context_window == 256000 @@ -359,11 +359,20 @@ def test_threatbook_cn_llm_catalog(self): qwen = next(m for m in models if m.id == "qwen3.6-plus") assert qwen.capabilities.supports_vision is True + qwen38 = next(m for m in models if m.id == "qwen3.8-max") + assert qwen38.capabilities.supports_vision is True + assert qwen38.limits.context_window == 1000000 + assert qwen38.limits.max_output_tokens == 65536 + assert qwen38.pricing.input == 12.0 + assert qwen38.pricing.output == 36.0 m3 = next(m for m in models if m.id == "minimax-m3") assert m3.capabilities.supports_vision is True assert m3.capabilities.supports_reasoning is True assert m3.capabilities.interleaved["field"] == "reasoning_details" + m25 = next(m for m in models if m.id == "minimax-m2.5") + assert m25.pricing.input == 2.1 + assert m25.pricing.output == 8.42 flash_cn = next(m for m in models if m.id == "deepseek-v4-flash") assert flash_cn.pricing.input == 1.0 @@ -379,10 +388,6 @@ def test_threatbook_cn_llm_catalog(self): **raw_models["deepseek-v4-flash"]["limits"], "max_input_tokens": 1000000, }, - "pricing": { - **raw_models["deepseek-v4-flash"]["pricing"], - "cache_read": 0.2, - }, } kimi = next(m for m in models if m.id == "kimi-k2.6") @@ -390,7 +395,7 @@ def test_threatbook_cn_llm_catalog(self): assert kimi.capabilities.supports_reasoning is True assert kimi.capabilities.interleaved["field"] == "reasoning_content" assert kimi.pricing.currency == "CNY" - assert kimi.pricing.cache_read == 1.3 + assert kimi.pricing.cache_read is None assert kimi.pricing.input == 6.5 assert kimi.pricing.output == 27.0 assert kimi.limits.context_window == 256000 diff --git a/tests/server/routes/test_provider_model_bootstrap.py b/tests/server/routes/test_provider_model_bootstrap.py index 1dbef2c83..8920a503e 100644 --- a/tests/server/routes/test_provider_model_bootstrap.py +++ b/tests/server/routes/test_provider_model_bootstrap.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock +import httpx import pytest from flocks.config.config_writer import ConfigWriter @@ -7,10 +8,152 @@ get_provider_model_definitions, sync_catalog_models_to_config, ) +from flocks.provider.provider import ModelInfo, ProviderConfig +from flocks.provider.sdk import threatbook +from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider from flocks.server.routes import provider as provider_routes class TestThreatBookProviderModelBootstrap: + @pytest.mark.asyncio + async def test_router_refresh_discovers_models_and_uses_first_tier_price( + self, monkeypatch: pytest.MonkeyPatch + ): + payload = { + "code": 0, + "msg": "ok", + "data": [ + { + "model_name": "MiniMax-M3", + "input_price": 99, + "output_price": 99, + "price_tiers": [ + { + "max_input_tokens": 512000, + "input_price": 4.2, + "output_price": 16.8, + }, + { + "max_input_tokens": None, + "input_price": 8.4, + "output_price": 33.6, + }, + ], + }, + { + "model_name": "Qwen3.8-Max", + "input_price": 12, + "output_price": 36, + }, + { + "model_name": "Router-New", + "input_price": 3, + "output_price": 7, + }, + ], + } + requests = [] + + class FakeClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def get(self, url, headers=None): + requests.append((url, headers)) + return httpx.Response( + 200, + request=httpx.Request("GET", url), + json=payload, + ) + + monkeypatch.setattr(threatbook.httpx, "AsyncClient", FakeClient) + provider = ThreatBookCnLLMProvider() + provider.configure(ProviderConfig( + provider_id=provider.id, + api_key="test-key", + base_url="https://router.example/v1", + custom_settings={"trust_env": False}, + )) + + assert await provider.refresh_models(force=True) is True + models = {model.id: model for model in provider.get_models()} + assert set(models) == {"minimax-m3", "qwen3.8-max", "Router-New"} + assert models["minimax-m3"].pricing == { + "input": 4.2, + "output": 16.8, + "currency": "CNY", + } + assert models["qwen3.8-max"].pricing == { + "input": 12.0, + "output": 36.0, + "currency": "CNY", + } + definitions = {model.id: model for model in provider.get_model_definitions()} + assert definitions["qwen3.8-max"].capabilities.supports_tools is True + assert definitions["qwen3.8-max"].capabilities.supports_vision is True + assert definitions["qwen3.8-max"].limits.context_window == 1000000 + assert definitions["Router-New"].pricing.input == 3.0 + assert requests[0][0] == "https://router.example/v1/models" + assert requests[0][1]["Authorization"] == "Bearer test-key" + + @pytest.mark.asyncio + async def test_router_refresh_failure_keeps_config_fallback( + self, monkeypatch: pytest.MonkeyPatch + ): + class FailingClient: + def __init__(self, **_kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return False + + async def get(self, url, headers=None): + return httpx.Response( + 404, + request=httpx.Request("GET", url), + json={"error": {"message": "not found"}}, + ) + + monkeypatch.setattr(threatbook.httpx, "AsyncClient", FailingClient) + provider = ThreatBookCnLLMProvider() + provider._config_models = [ + ModelInfo( + id="fallback-model", + name="fallback-model", + provider_id=provider.id, + ) + ] + provider.configure(ProviderConfig( + provider_id=provider.id, + api_key="test-key", + base_url="https://router.example/v1", + custom_settings={"trust_env": False}, + )) + + assert await provider.refresh_models(force=True) is False + models = {model.id: model for model in provider.get_models()} + assert "fallback-model" not in models + assert models["minimax-m3"].pricing == { + "input": 4.2, + "output": 16.8, + "currency": "CNY", + } + assert models["minimax-m2.5"].pricing["output"] == 8.42 + assert models["qwen3.8-max"].pricing == { + "input": 12.0, + "output": 36.0, + "currency": "CNY", + } + @pytest.mark.asyncio async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): result = await provider_routes.get_provider_catalog() @@ -30,7 +173,9 @@ async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): assert model["pricing"] == { "input": 1.0, "output": 2.0, - "cache_read": 0.2, + "cache_read": ( + None if provider_id == "threatbook-cn-llm" else 0.2 + ), "cache_write": None, "currency": "CNY", } diff --git a/webui/src/components/common/OnboardingModal.tsx b/webui/src/components/common/OnboardingModal.tsx index e64bad319..8d7b6e0fe 100644 --- a/webui/src/components/common/OnboardingModal.tsx +++ b/webui/src/components/common/OnboardingModal.tsx @@ -262,7 +262,7 @@ export default function OnboardingModal({ onClose }: OnboardingModalProps) { || provider.models.length > 0 ); - const preferredOrder = ['threatbook-cn-llm', 'threatbook-io-llm', 'flocks-router-test']; + const preferredOrder = ['threatbook-cn-llm', 'threatbook-io-llm']; return filtered.sort((a, b) => { const aIndex = preferredOrder.indexOf(a.id); const bIndex = preferredOrder.indexOf(b.id); @@ -355,7 +355,6 @@ export default function OnboardingModal({ onClose }: OnboardingModalProps) { const getProviderLabel = (provider: CatalogProvider) => { if (provider.id === 'threatbook-cn-llm') return t('onboarding.bootstrap.providerThreatBookCn'); if (provider.id === 'threatbook-io-llm') return t('onboarding.bootstrap.providerThreatBookGlobal'); - if (provider.id === 'flocks-router-test') return t('onboarding.bootstrap.providerFlocksRouterTest'); return provider.name; }; diff --git a/webui/src/locales/en-US/common.json b/webui/src/locales/en-US/common.json index f144fbcbb..64d34aa41 100644 --- a/webui/src/locales/en-US/common.json +++ b/webui/src/locales/en-US/common.json @@ -181,7 +181,6 @@ "primaryThreatBookGlobalLink": "Global users claim API key", "providerThreatBookCn": "ThreatBook-China free model", "providerThreatBookGlobal": "ThreatBook Global free model", - "providerFlocksRouterTest": "Flocks Router Test", "savePrimary": "Save & Verify Model", "saveOptionalThreatBook": "Save & Verify ThreatBook Services", "primaryThreatBookCnHint": "After validation, Flocks will also configure the ThreatBook-China model, API, and MCP services.", diff --git a/webui/src/locales/zh-CN/common.json b/webui/src/locales/zh-CN/common.json index db76cc615..c5fee2597 100644 --- a/webui/src/locales/zh-CN/common.json +++ b/webui/src/locales/zh-CN/common.json @@ -181,7 +181,6 @@ "primaryThreatBookGlobalLink": "国际区用户领取API Key", "providerThreatBookCn": "ThreatBook 中国区免费模型", "providerThreatBookGlobal": "ThreatBook 国际区免费模型", - "providerFlocksRouterTest": "Flocks Router 测试环境", "savePrimary": "保存并验证模型", "saveOptionalThreatBook": "保存并验证微步服务", "primaryThreatBookCnHint": "验证成功后,将同时配置 ThreatBook 中国区模型、API 和 MCP。", From d938f5089612d9067d005e04157eba54c0f6fb25 Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Wed, 26 Aug 2026 15:46:23 +0800 Subject: [PATCH 3/6] feat(provider): sync ThreatBook Router catalog pricing --- flocks/provider/catalog.json | 135 ++++++-- flocks/provider/cost_calculator.py | 17 +- flocks/provider/model_catalog.py | 2 + flocks/provider/provider.py | 10 + flocks/provider/sdk/threatbook.py | 288 +++++++++++++----- flocks/provider/types.py | 13 + flocks/provider/usage_service.py | 4 + flocks/server/routes/provider.py | 61 +++- tests/provider/test_chinese_providers.py | 27 +- tests/provider/test_model_management_p2p3.py | 31 ++ .../routes/test_provider_model_bootstrap.py | 239 ++++++++++++--- webui/src/locales/en-US/model.json | 5 +- webui/src/locales/zh-CN/model.json | 5 +- webui/src/pages/Model/index.test.tsx | 66 +++- webui/src/pages/Model/index.tsx | 94 +++++- .../Model/providerCredentialUtils.test.ts | 17 ++ .../pages/Model/providerCredentialUtils.ts | 4 +- webui/src/types/index.ts | 13 + webui/src/utils/modelPricing.test.ts | 14 +- webui/src/utils/modelPricing.ts | 34 ++- 20 files changed, 902 insertions(+), 177 deletions(-) diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index 6e214a060..d870cd100 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -34,6 +34,7 @@ "description": "ThreatBook China LLM Service (OpenAI-compatible)", "npm": "@ai-sdk/openai-compatible", "default_base_url": "https://llm.threatbook.cn/v1", + "default_model_catalog_url": "https://flocks-router-test.threatbook-inc.cn/api/console/common/models", "credential_schemas": [ { "auth_method": "api_key", @@ -49,11 +50,12 @@ } ], "env_vars": [ - "THREATBOOK_CN_LLM_API_KEY" + "THREATBOOK_CN_LLM_API_KEY", + "THREATBOOK_CN_LLM_MODEL_CATALOG_URL" ], "models": { "deepseek-v4-flash-0731": { - "name": "deepseek-v4-flash-0731", + "name": "DeepSeek-V4-Flash-0731", "family": "deepseek-v4", "capabilities": { "supports_tools": true, @@ -75,11 +77,34 @@ "pricing": { "input": 1.0, "output": 2.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026081405", + "price_tiers": [ + { + "max_input_tokens": 100000, + "input_price": 1.0, + "output_price": 2.0 + }, + { + "max_input_tokens": 10000000, + "input_price": 2.0, + "output_price": 4.0 + }, + { + "max_input_tokens": 100000000, + "input_price": 1.0, + "output_price": 2.0 + }, + { + "max_input_tokens": null, + "input_price": 3.0, + "output_price": 6.0 + } + ] } }, "kimi-k2.7-code": { - "name": "kimi-k2.7-code", + "name": "Kimi-K2.7-Code", "family": "kimi-k2.7-code", "capabilities": { "supports_tools": true, @@ -104,11 +129,12 @@ "pricing": { "input": 6.5, "output": 27.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026072001" } }, "minimax-m3": { - "name": "minimax-m3", + "name": "MiniMax-M3", "family": "minimax", "capabilities": { "supports_tools": true, @@ -132,11 +158,23 @@ "input": 4.2, "output": 16.8, "currency": "CNY", - "note": "≤512k input: ¥4.20/M tokens output: ¥16.80/M tokens; >512k input: ¥8.40/M tokens output: ¥33.60/M tokens" + "price_version": "2026061601", + "price_tiers": [ + { + "max_input_tokens": 512000, + "input_price": 4.2, + "output_price": 16.8 + }, + { + "max_input_tokens": null, + "input_price": 8.4, + "output_price": 33.6 + } + ] } }, "minimax-m2.7": { - "name": "minimax-m2.7", + "name": "MiniMax-M2.7", "family": "minimax", "capabilities": { "supports_tools": true, @@ -158,11 +196,12 @@ "pricing": { "input": 2.1, "output": 8.4, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" } }, "minimax-m2.5": { - "name": "minimax-m2.5", + "name": "MiniMax-M2.5", "family": "minimax", "capabilities": { "supports_tools": true, @@ -184,7 +223,8 @@ "pricing": { "input": 2.1, "output": 8.42, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026081401" } }, "GLM-5": { @@ -210,11 +250,12 @@ "pricing": { "input": 4.0, "output": 18.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" } }, "qwen3.6-plus": { - "name": "qwen3.6-plus", + "name": "Qwen3.6-Plus", "family": "qwen", "capabilities": { "supports_tools": true, @@ -237,11 +278,12 @@ "pricing": { "input": 2.0, "output": 12.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" } }, "qwen3-max": { - "name": "qwen3-max", + "name": "Qwen3-Max", "family": "qwen", "capabilities": { "supports_tools": true, @@ -263,11 +305,12 @@ "pricing": { "input": 2.5, "output": 10.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" } }, "qwen3.8-max": { - "name": "qwen3.8-max", + "name": "Qwen3.8-Max", "family": "qwen3.8", "capabilities": { "supports_tools": true, @@ -290,11 +333,12 @@ "pricing": { "input": 12.0, "output": 36.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026080301" } }, "kimi-k2.6": { - "name": "kimi-k2.6", + "name": "Kimi-K2.6", "family": "kimi-k2.6", "capabilities": { "supports_tools": true, @@ -319,11 +363,12 @@ "pricing": { "input": 6.5, "output": 27.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" } }, "deepseek-v4-flash": { - "name": "deepseek-v4-flash", + "name": "DeepSeek-V4-Flash", "family": "deepseek-v4", "capabilities": { "supports_tools": true, @@ -344,7 +389,53 @@ "pricing": { "input": 1.0, "output": 2.0, - "currency": "CNY" + "currency": "CNY", + "price_version": "2026061601" + } + }, + "testadd": { + "name": "testadd", + "family": "testadd", + "capabilities": { + "supports_tools": true, + "supports_streaming": true + }, + "limits": { + "context_window": 128000, + "max_output_tokens": 4096 + }, + "pricing": { + "input": 2.0, + "output": 3.0, + "currency": "CNY", + "price_version": "2026081401" + } + }, + "gpt-4o": { + "name": "gpt-4o", + "family": "gpt-4o", + "capabilities": { + "supports_tools": true, + "supports_vision": true, + "thinking_level_map": { + "minimal": null, + "low": null, + "medium": null, + "high": null, + "xhigh": null, + "max": null + }, + "supports_streaming": true + }, + "limits": { + "context_window": 128000, + "max_output_tokens": 16384 + }, + "pricing": { + "input": 50.0, + "output": 60.0, + "currency": "CNY", + "price_version": "2026082501" } } } diff --git a/flocks/provider/cost_calculator.py b/flocks/provider/cost_calculator.py index d8d713f3e..34428e4a9 100644 --- a/flocks/provider/cost_calculator.py +++ b/flocks/provider/cost_calculator.py @@ -39,12 +39,25 @@ def calculate( """ unit = pricing.unit if pricing.unit > 0 else 1_000_000 + input_price = pricing.input + output_price = pricing.output + if pricing.price_tiers: + # Router selects a single tier from the request's complete prompt + # token count, then applies that tier to both input and output. + selected_tier = pricing.price_tiers[-1] + for tier in pricing.price_tiers: + if tier.max_input_tokens is None or input_tokens <= tier.max_input_tokens: + selected_tier = tier + break + input_price = selected_tier.input_price + output_price = selected_tier.output_price + # Input cost: non-cached tokens at input price billable_input = max(0, input_tokens - cached_tokens) - input_cost = (billable_input / unit) * pricing.input + input_cost = (billable_input / unit) * input_price # Output cost - output_cost = (output_tokens / unit) * pricing.output + output_cost = (output_tokens / unit) * output_price # Cache cost cache_cost = 0.0 diff --git a/flocks/provider/model_catalog.py b/flocks/provider/model_catalog.py index 3233bcbf0..c7f6ba8e0 100644 --- a/flocks/provider/model_catalog.py +++ b/flocks/provider/model_catalog.py @@ -159,6 +159,8 @@ def _parse_model_definitions( cache_read=pricing_raw.get("cache_read"), cache_write=pricing_raw.get("cache_write"), currency=pricing_raw.get("currency", "USD"), + price_tiers=pricing_raw.get("price_tiers"), + price_version=pricing_raw.get("price_version"), ) model_type_str = m.get("model_type", "llm") diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index 98403f547..d70361535 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -57,6 +57,12 @@ def _model_info_signature(model: "ModelInfo") -> tuple: pricing.get("cache_read") if isinstance(pricing, dict) else None, pricing.get("cache_write") if isinstance(pricing, dict) else None, pricing.get("currency") if isinstance(pricing, dict) else None, + json.dumps( + pricing.get("price_tiers") if isinstance(pricing, dict) else None, + default=str, + sort_keys=True, + ), + pricing.get("price_version") if isinstance(pricing, dict) else None, ) if pricing is not None else None @@ -1293,6 +1299,8 @@ def _build_model_definition(self, model: "ModelInfo") -> "ModelDefinition": cache_read=model.pricing.get("cache_read"), cache_write=model.pricing.get("cache_write"), currency=model.pricing.get("currency", "USD"), + price_tiers=model.pricing.get("price_tiers"), + price_version=model.pricing.get("price_version"), ) max_output = model.capabilities.max_tokens or 4096 return ModelDefinition( @@ -1398,6 +1406,8 @@ def _apply_config_overrides(self, catalog_def: "ModelDefinition", model: "ModelI cache_read=model.pricing.get("cache_read"), cache_write=model.pricing.get("cache_write"), currency=model.pricing.get("currency", "USD"), + price_tiers=model.pricing.get("price_tiers"), + price_version=model.pricing.get("price_version"), ) return overridden diff --git a/flocks/provider/sdk/threatbook.py b/flocks/provider/sdk/threatbook.py index c51d2baf2..18b47a619 100644 --- a/flocks/provider/sdk/threatbook.py +++ b/flocks/provider/sdk/threatbook.py @@ -1,15 +1,16 @@ """ThreatBook LLM provider implementations. -The China service is backed by Flocks Router. Router's ``GET /v1/models`` is -the authority for enabled models and their default (first-tier) prices. The -bundled catalog remains an offline fallback and supplies capability/limit -metadata that Router does not expose. +The China service is backed by Flocks Router. Router's model catalog is the +authority for enabled models, names, prices, versions, and input-token price +tiers. The bundled catalog remains an offline fallback and supplies +capability/limit metadata that Router does not expose. """ import asyncio import os import time from typing import Any, Optional +from urllib.parse import urlsplit, urlunsplit import httpx @@ -26,6 +27,9 @@ class ThreatBookCnLLMProvider(OpenAIBaseProvider): """ThreatBook-China LLM provider (OpenAI-compatible).""" DEFAULT_BASE_URL = "https://llm.threatbook.cn/v1" + DEFAULT_MODEL_CATALOG_URL = ( + "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" + ) ENV_API_KEY = ["THREATBOOK_CN_LLM_API_KEY"] ENV_BASE_URL = "THREATBOOK_CN_LLM_BASE_URL" CATALOG_ID = "threatbook-cn-llm" @@ -57,10 +61,14 @@ def get_models(self) -> list[ModelInfo]: # snapshot so those stale values cannot override current defaults. fallback_rows = [] for model in get_provider_model_definitions(self.CATALOG_ID): - row: dict[str, Any] = {"model_name": model.id} + row: dict[str, Any] = {"model_name": model.name} if model.pricing: row["input_price"] = model.pricing.input row["output_price"] = model.pricing.output + row["price_tiers"] = ( + [tier.model_dump() for tier in model.pricing.price_tiers] if model.pricing.price_tiers else None + ) + row["price_version"] = model.pricing.price_version fallback_rows.append(row) return self._build_router_models(fallback_rows) @@ -72,9 +80,27 @@ def model_catalog_is_authoritative(self) -> bool: def _get_model_definition_source_models(self) -> list[ModelInfo]: return self.get_models() - def _effective_base_url(self) -> str: - configured = self._config.base_url if self._config else None - return (configured or self._base_url or self.DEFAULT_BASE_URL).rstrip("/") + def _model_catalog_urls(self) -> list[str]: + """Return catalog sources in consistency-preferred order. + + Console's common-model endpoint is the fee-details page's exact data + source. It currently requires a Passport ``session_token`` cookie, so + the API-key-protected ``/v1/models`` endpoint remains the runtime + fallback for ordinary Flocks installations. Both response shapes are + parsed identically below. + """ + custom_settings = getattr(self._config, "custom_settings", None) or {} + configured_url = ( + custom_settings.get("model_catalog_url") if isinstance(custom_settings, dict) else None + ) or os.getenv("THREATBOOK_CN_LLM_MODEL_CATALOG_URL") + + catalog_url = configured_url or self.DEFAULT_MODEL_CATALOG_URL + urls = [catalog_url] + if "/api/console/" in urlsplit(catalog_url).path: + parsed = urlsplit(catalog_url) + origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) + urls.append(f"{origin}/v1/models") + return list(dict.fromkeys(urls)) @staticmethod def _positive_float_env( @@ -99,6 +125,49 @@ def _price_value(value: Any) -> Optional[float]: return None return parsed if parsed >= 0 else None + @classmethod + def _router_price_tiers(cls, raw: dict[str, Any]) -> list[dict[str, Any]]: + tiers = raw.get("price_tiers", raw.get("priceTiers")) + if not isinstance(tiers, list): + return [] + + parsed_tiers: list[dict[str, Any]] = [] + for tier in tiers: + if not isinstance(tier, dict): + continue + input_price = cls._price_value(tier.get("input_price", tier.get("inputPrice"))) + output_price = cls._price_value(tier.get("output_price", tier.get("outputPrice"))) + if input_price is None or output_price is None: + continue + + raw_max = tier.get("max_input_tokens", tier.get("maxInputTokens")) + if raw_max is None: + max_input_tokens = None + elif isinstance(raw_max, bool): + continue + else: + try: + max_input_tokens = int(raw_max) + except (TypeError, ValueError): + continue + if max_input_tokens < 0: + continue + parsed_tiers.append( + { + "max_input_tokens": max_input_tokens, + "input_price": input_price, + "output_price": output_price, + } + ) + + return sorted( + parsed_tiers, + key=lambda tier: ( + tier["max_input_tokens"] is None, + tier["max_input_tokens"] or 0, + ), + ) + @classmethod def _router_default_prices( cls, @@ -110,36 +179,17 @@ def _router_default_prices( as the first tier. Prefer the explicit first tier defensively so Flocks still matches the fee page if those fields temporarily drift. """ - input_price = cls._price_value( - raw.get("input_price", raw.get("inputPrice")) - ) - output_price = cls._price_value( - raw.get("output_price", raw.get("outputPrice")) - ) - tiers = raw.get("price_tiers", raw.get("priceTiers")) - if isinstance(tiers, list) and tiers and isinstance(tiers[0], dict): - first = tiers[0] - tier_input = cls._price_value( - first.get("input_price", first.get("inputPrice")) - ) - tier_output = cls._price_value( - first.get("output_price", first.get("outputPrice")) - ) - if tier_input is not None: - input_price = tier_input - if tier_output is not None: - output_price = tier_output + input_price = cls._price_value(raw.get("input_price", raw.get("inputPrice"))) + output_price = cls._price_value(raw.get("output_price", raw.get("outputPrice"))) + tiers = cls._router_price_tiers(raw) + if tiers: + input_price = tiers[0]["input_price"] + output_price = tiers[0]["output_price"] return input_price, output_price def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: - catalog_by_lower = { - model.id.lower(): model - for model in get_provider_model_definitions(self.CATALOG_ID) - } - configured_by_lower = { - model.id.lower(): model - for model in getattr(self, "_config_models", []) - } + catalog_by_lower = {model.id.lower(): model for model in get_provider_model_definitions(self.CATALOG_ID)} + configured_by_lower = {model.id.lower(): model for model in getattr(self, "_config_models", [])} result: list[ModelInfo] = [] seen: set[str] = set() @@ -170,14 +220,12 @@ def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: max_tokens=catalog.limits.max_output_tokens, context_window=catalog.limits.context_window, ) - display_name = catalog.name + display_name = router_name else: capabilities = ModelCapabilities() display_name = router_name - explicit_keys = set( - getattr(configured, "_explicit_keys", set()) if configured else set() - ) + explicit_keys = set(getattr(configured, "_explicit_keys", set()) if configured else set()) if configured: configured_capabilities = configured.capabilities capability_fields = { @@ -198,10 +246,17 @@ def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: attribute, getattr(configured_capabilities, attribute), ) - if "name" in explicit_keys: - display_name = configured.name + # Router owns model identity and display casing. Persisted + # names from older flocks.json snapshots must not overwrite + # the current Console model name. input_price, output_price = self._router_default_prices(raw) + price_tiers = self._router_price_tiers(raw) + raw_price_version = raw.get( + "price_version", + raw.get("priceVersion"), + ) + price_version = str(raw_price_version) if raw_price_version is not None else None pricing = None if input_price is not None and output_price is not None: pricing = { @@ -209,12 +264,20 @@ def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: "output": output_price, "currency": "CNY", } + if price_tiers: + pricing["price_tiers"] = price_tiers + if price_version: + pricing["price_version"] = price_version elif catalog and catalog.pricing: pricing = { "input": catalog.pricing.input, "output": catalog.pricing.output, "currency": catalog.pricing.currency, } + if catalog.pricing.price_tiers: + pricing["price_tiers"] = [tier.model_dump() for tier in catalog.pricing.price_tiers] + if catalog.pricing.price_version: + pricing["price_version"] = catalog.pricing.price_version model = ModelInfo( id=model_id, @@ -222,23 +285,64 @@ def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: provider_id=self.id, capabilities=capabilities, pricing=pricing, - custom_settings=( - dict(configured.custom_settings) if configured else {} - ), + custom_settings=(dict(configured.custom_settings) if configured else {}), ) model._explicit_keys = explicit_keys result.append(model) return result + async def _fetch_catalog_rows( + self, + client: httpx.AsyncClient, + models_url: str, + headers: dict[str, str], + cookies: Optional[dict[str, str]], + ) -> list[Any]: + """Fetch either Console's paginated shape or ``/v1/models``.""" + page = 1 + page_size = 100 + rows: list[Any] = [] + + while True: + params = {"page": page, "pageSize": page_size} if "/api/console/" in models_url else None + response = await client.get( + models_url, + headers=headers, + cookies=cookies, + params=params, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("Router model response is not an object") + if "code" in payload and payload.get("code") != 0: + raise ValueError(f"Router model response failed with code {payload.get('code')}") + + data = payload.get("data") + if isinstance(data, list): + return data + if not isinstance(data, dict) or not isinstance(data.get("list"), list): + raise ValueError("Router model response has no model list") + + rows.extend(data["list"]) + total = data.get("total", len(rows)) + try: + total = max(0, int(total)) + except (TypeError, ValueError): + total = len(rows) + if len(rows) >= total or not data["list"]: + return rows + page += 1 + async def refresh_models(self, force: bool = False) -> bool: - """Refresh active models and default prices from Flocks Router. + """Refresh active models and complete pricing from Flocks Router. The refresh is rate-limited and failure-safe. A failed request never clears the last successful Router snapshot or the bundled fallback. """ - base_url = self._effective_base_url() - models_url = f"{base_url}/models" + models_urls = self._model_catalog_urls() + attempt_key = "|".join(models_urls) now = time.monotonic() ttl = self._positive_float_env( "THREATBOOK_CN_LLM_MODEL_CACHE_TTL_SECONDS", @@ -247,7 +351,7 @@ async def refresh_models(self, force: bool = False) -> bool: ) if ( not force - and models_url == self._router_models_last_attempt_url + and attempt_key == self._router_models_last_attempt_url and now - self._router_models_last_attempt < ttl ): return self._router_models is not None @@ -256,12 +360,12 @@ async def refresh_models(self, force: bool = False) -> bool: now = time.monotonic() if ( not force - and models_url == self._router_models_last_attempt_url + and attempt_key == self._router_models_last_attempt_url and now - self._router_models_last_attempt < ttl ): return self._router_models is not None - if self._router_models_url and models_url != self._router_models_url: + if self._router_models_url and self._router_models_url not in models_urls: # Never carry an authoritative snapshot across environments. # If the new Router is unavailable, fall back to the bundled # catalog/config rather than showing models from the old URL. @@ -269,7 +373,7 @@ async def refresh_models(self, force: bool = False) -> bool: self._router_models_url = None self._router_models_last_attempt = now - self._router_models_last_attempt_url = models_url + self._router_models_last_attempt_url = attempt_key custom_settings = getattr(self._config, "custom_settings", None) or {} verify_ssl = resolve_verify_ssl(custom_settings, default=True) trust_env = _coerce_bool(os.getenv("FLOCKS_HTTP_TRUST_ENV"), True) @@ -284,42 +388,58 @@ async def refresh_models(self, force: bool = False) -> bool: if api_key: headers["Authorization"] = f"Bearer {api_key}" - try: - async with httpx.AsyncClient( - timeout=timeout_seconds, - verify=verify_ssl, - trust_env=trust_env, - ) as client: - response = await client.get(models_url, headers=headers) - response.raise_for_status() - payload = response.json() - if not isinstance(payload, dict): - raise ValueError("Router model response is not an object") - if "code" in payload and payload.get("code") != 0: - raise ValueError( - f"Router model response failed with code {payload.get('code')}" - ) - rows = payload.get("data") - if not isinstance(rows, list): - raise ValueError("Router model response data is not a list") - models = self._build_router_models(rows) - if rows and not models: - raise ValueError("Router model response has no valid model entries") - except Exception as exc: - self.log.warning("router.models.refresh_failed", { - "url": models_url, - "error_type": type(exc).__name__, - "error": str(exc), - "using_fallback": self._router_models is None, - }) + session_token = os.getenv("THREATBOOK_CN_LLM_CONSOLE_SESSION_TOKEN") + cookies = {"session_token": session_token} if session_token else None + + models = None + successful_url = None + errors: list[tuple[str, Exception]] = [] + async with httpx.AsyncClient( + timeout=timeout_seconds, + verify=verify_ssl, + trust_env=trust_env, + ) as client: + for models_url in models_urls: + try: + rows = await self._fetch_catalog_rows( + client, + models_url, + headers, + cookies, + ) + candidate_models = self._build_router_models(rows) + if rows and not candidate_models: + raise ValueError("Router model response has no valid model entries") + except Exception as exc: + errors.append((models_url, exc)) + continue + models = candidate_models + successful_url = models_url + break + + if models is None or successful_url is None: + failed_url, exc = errors[-1] + self.log.warning( + "router.models.refresh_failed", + { + "url": failed_url, + "attempted_urls": [url for url, _ in errors], + "error_type": type(exc).__name__, + "error": str(exc), + "using_fallback": self._router_models is None, + }, + ) return False self._router_models = models - self._router_models_url = models_url - self.log.info("router.models.refreshed", { - "url": models_url, - "count": len(models), - }) + self._router_models_url = successful_url + self.log.info( + "router.models.refreshed", + { + "url": successful_url, + "count": len(models), + }, + ) return True diff --git a/flocks/provider/types.py b/flocks/provider/types.py index 51d6915d8..d371e6c0f 100644 --- a/flocks/provider/types.py +++ b/flocks/provider/types.py @@ -226,6 +226,17 @@ class ModelLimits(BaseModel): max_output_tokens: int = 4096 +class PriceTierConfig(BaseModel): + """Input-token price tier (prices are per ``PriceConfig.unit`` tokens).""" + max_input_tokens: Optional[int] = Field( + None, + ge=0, + description="Inclusive input-token upper bound; null means no upper bound", + ) + input_price: float = Field(0.0, ge=0) + output_price: float = Field(0.0, ge=0) + + class PriceConfig(BaseModel): """价格配置 (每百万 token)""" input: float = 0.0 @@ -234,6 +245,8 @@ class PriceConfig(BaseModel): currency: str = "USD" cache_read: Optional[float] = None cache_write: Optional[float] = None + price_tiers: Optional[List[PriceTierConfig]] = None + price_version: Optional[str] = None class ModelCapabilitiesV2(BaseModel): diff --git a/flocks/provider/usage_service.py b/flocks/provider/usage_service.py index 912259960..657b77c16 100644 --- a/flocks/provider/usage_service.py +++ b/flocks/provider/usage_service.py @@ -156,6 +156,8 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf currency=getattr(pricing, "currency", "USD"), cache_read=getattr(pricing, "cache_read", None), cache_write=getattr(pricing, "cache_write", None), + price_tiers=getattr(pricing, "price_tiers", None), + price_version=getattr(pricing, "price_version", None), ) if isinstance(pricing, dict): @@ -166,6 +168,8 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf currency=pricing.get("currency", "USD"), cache_read=pricing.get("cache_read"), cache_write=pricing.get("cache_write"), + price_tiers=pricing.get("price_tiers"), + price_version=pricing.get("price_version"), ) return None diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 641703e18..e8f408bfb 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -8,11 +8,13 @@ import asyncio import json +import os import re import threading import time from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.parse import urlparse from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status from pydantic import BaseModel, Field, ConfigDict @@ -668,6 +670,7 @@ async def get_provider_catalog(): "credential_schemas": [s.model_dump() for s in meta.credential_schemas], "env_vars": raw.get("env_vars", []), "default_base_url": raw.get("default_base_url"), + "default_model_catalog_url": raw.get("default_model_catalog_url"), "model_count": len(models), "models": [ { @@ -693,6 +696,10 @@ async def get_provider_catalog(): "cache_read": m.pricing.cache_read, "cache_write": m.pricing.cache_write, "currency": m.pricing.currency, + "price_tiers": [ + tier.model_dump() for tier in m.pricing.price_tiers + ] if m.pricing.price_tiers else None, + "price_version": m.pricing.price_version, } if m.pricing else None, } for m in models @@ -1624,7 +1631,6 @@ async def get_api_service_metadata(provider_id: str): if not base_url and data.get("apis") and len(data["apis"]) > 0: first_endpoint = data["apis"][0].get("endpoint", "") if first_endpoint: - from urllib.parse import urlparse parsed = urlparse(first_endpoint) base_url = f"{parsed.scheme}://{parsed.netloc}" @@ -1677,6 +1683,10 @@ class ProviderCredentialRequest(BaseModel): api_key: Optional[str] = Field(None, description="API key value") secret: Optional[str] = Field(None, description="Secondary secret value for custom API services") base_url: Optional[str] = Field(None, description="Base URL for the provider") + model_catalog_url: Optional[str] = Field( + None, + description="Model catalog URL, independent from the provider chat Base URL", + ) username: Optional[str] = Field(None, description="Optional username for API services") fields: Optional[Dict[str, Optional[str]]] = Field(None, description="Dynamic service credential fields") provider_name: Optional[str] = Field(None, description="Display name for the provider") @@ -1690,6 +1700,7 @@ class ProviderCredentialResponse(BaseModel): secret: Optional[str] = None secret_masked: Optional[str] = None base_url: Optional[str] = None + model_catalog_url: Optional[str] = None username: Optional[str] = None fields: Optional[Dict[str, Optional[str]]] = None secret_ids: Optional[Dict[str, str]] = None @@ -1714,12 +1725,27 @@ def _load_llm_provider_credentials( api_key = _get_inline_provider_api_key(provider_id) base_url = None + model_catalog_url = None raw_provider = ConfigWriter.get_provider_raw(provider_id) if raw_provider: options = raw_provider.get("options", {}) base_url = options.get("baseURL") or options.get("base_url") + model_catalog_url = options.get("model_catalog_url") or options.get( + "modelCatalogURL" + ) if not base_url: base_url = secrets.get(f"{provider_id}_base_url") + if not model_catalog_url: + model_catalog_url = os.getenv("THREATBOOK_CN_LLM_MODEL_CATALOG_URL") + if not model_catalog_url: + try: + from flocks.provider.model_catalog import get_raw_catalog + + model_catalog_url = get_raw_catalog().get(provider_id, {}).get( + "default_model_catalog_url" + ) + except Exception: + pass is_placeholder = _is_placeholder_api_key(api_key) ui_api_key = None if is_placeholder else api_key @@ -1729,6 +1755,7 @@ def _load_llm_provider_credentials( api_key=ui_api_key if reveal_api_key else None, api_key_masked=SecretManager.mask(ui_api_key) if ui_api_key else None, base_url=base_url, + model_catalog_url=model_catalog_url, has_credential=bool(api_key), ) @@ -1815,6 +1842,7 @@ async def set_provider_credentials( - api_key → .secret.json - base_url → flocks.json provider.{id}.options.baseURL + - model_catalog_url → flocks.json provider.{id}.options.model_catalog_url - Ensures provider entry exists in flocks.json - Configures provider runtime immediately """ @@ -1862,6 +1890,21 @@ async def set_provider_credentials( # (which require a non-empty key argument) keep working. effective_api_key = _NO_API_KEY_PLACEHOLDER + model_catalog_url_was_set = "model_catalog_url" in request.model_fields_set + effective_model_catalog_url = None + if model_catalog_url_was_set: + effective_model_catalog_url = (request.model_catalog_url or "").strip() + if effective_model_catalog_url: + parsed_catalog_url = urlparse(effective_model_catalog_url) + if ( + parsed_catalog_url.scheme not in {"http", "https"} + or not parsed_catalog_url.netloc + ): + raise HTTPException( + status_code=400, + detail="Model catalog URL must be an absolute HTTP(S) URL", + ) + # 1. Save API key to .secret.json using _llm_key convention for LLM providers if preserve_existing_secret: log.info("provider.credentials.preserved", { @@ -1904,6 +1947,12 @@ async def set_provider_credentials( ConfigWriter.update_provider_field( provider_id, "name", request.provider_name ) + if model_catalog_url_was_set: + ConfigWriter.update_provider_field( + provider_id, + "options.model_catalog_url", + effective_model_catalog_url or None, + ) else: # Provider not yet in flocks.json — create a minimal entry # Use model_catalog for model defaults; fall back to SDK built-ins. @@ -1946,6 +1995,11 @@ async def set_provider_credentials( npm=npm, base_url=effective_base_url, models=models, + extra_options=( + {"model_catalog_url": effective_model_catalog_url} + if effective_model_catalog_url + else None + ), ) if request.provider_name: config_dict["name"] = request.provider_name @@ -1968,6 +2022,11 @@ async def set_provider_credentials( effective_base_url = raw.get("options", {}).get("baseURL") custom_settings = _get_provider_custom_settings(provider) + if model_catalog_url_was_set: + if effective_model_catalog_url: + custom_settings["model_catalog_url"] = effective_model_catalog_url + else: + custom_settings.pop("model_catalog_url", None) provider.configure(ProviderConfig( provider_id=provider_id, api_key=effective_api_key, diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index b57392497..2c054945c 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -342,6 +342,8 @@ def test_threatbook_cn_llm_catalog(self): "kimi-k2.6", "deepseek-v4-flash", "deepseek-v4-flash-0731", + "testadd", + "gpt-4o", } assert models[0].id == "deepseek-v4-flash-0731" @@ -353,6 +355,7 @@ def test_threatbook_cn_llm_catalog(self): assert kimi_code.pricing.cache_read is None assert kimi_code.pricing.input == 6.5 assert kimi_code.pricing.output == 27.0 + assert kimi_code.pricing.price_version == "2026072001" assert kimi_code.limits.context_window == 256000 assert kimi_code.limits.max_input_tokens == 224000 assert kimi_code.limits.max_output_tokens == 16000 @@ -370,6 +373,12 @@ def test_threatbook_cn_llm_catalog(self): assert m3.capabilities.supports_vision is True assert m3.capabilities.supports_reasoning is True assert m3.capabilities.interleaved["field"] == "reasoning_details" + assert m3.name == "MiniMax-M3" + assert m3.pricing.price_version == "2026061601" + assert len(m3.pricing.price_tiers) == 2 + assert m3.pricing.price_tiers[0].max_input_tokens == 512000 + assert m3.pricing.price_tiers[1].input_price == 8.4 + assert m3.pricing.price_tiers[1].output_price == 33.6 m25 = next(m for m in models if m.id == "minimax-m2.5") assert m25.pricing.input == 2.1 assert m25.pricing.output == 8.42 @@ -381,14 +390,16 @@ def test_threatbook_cn_llm_catalog(self): assert flash_cn.limits.context_window == 1000000 assert flash_cn.limits.max_output_tokens == 384000 raw_models = get_raw_catalog()["threatbook-cn-llm"]["models"] - assert raw_models["deepseek-v4-flash-0731"] == { - **raw_models["deepseek-v4-flash"], - "name": "deepseek-v4-flash-0731", - "limits": { - **raw_models["deepseek-v4-flash"]["limits"], - "max_input_tokens": 1000000, - }, - } + flash_0731 = next(m for m in models if m.id == "deepseek-v4-flash-0731") + assert flash_0731.name == "DeepSeek-V4-Flash-0731" + assert flash_0731.pricing.price_version == "2026081405" + assert [tier.max_input_tokens for tier in flash_0731.pricing.price_tiers] == [ + 100000, + 10000000, + 100000000, + None, + ] + assert raw_models["deepseek-v4-flash-0731"]["limits"]["max_input_tokens"] == 1000000 kimi = next(m for m in models if m.id == "kimi-k2.6") assert kimi.capabilities.supports_vision is True diff --git a/tests/provider/test_model_management_p2p3.py b/tests/provider/test_model_management_p2p3.py index e4ff4ea61..c0d8912aa 100644 --- a/tests/provider/test_model_management_p2p3.py +++ b/tests/provider/test_model_management_p2p3.py @@ -23,6 +23,7 @@ ModelSetting, ModelType, PriceConfig, + PriceTierConfig, UsageCost, ) @@ -432,6 +433,36 @@ def test_no_cache_pricing(self): # cache_read is None, so no cache cost assert cost.cache_cost == 0.0 + def test_router_tiered_pricing_uses_prompt_token_bucket(self): + from flocks.provider.cost_calculator import CostCalculator + + pricing = PriceConfig( + input=4.2, + output=16.8, + currency="CNY", + price_tiers=[ + PriceTierConfig( + max_input_tokens=512000, + input_price=4.2, + output_price=16.8, + ), + PriceTierConfig( + max_input_tokens=None, + input_price=8.4, + output_price=33.6, + ), + ], + ) + + first_tier = CostCalculator.calculate(512000, 100000, pricing) + assert first_tier.input_cost == 2.1504 + assert first_tier.output_cost == 1.68 + + second_tier = CostCalculator.calculate(600000, 100000, pricing) + assert second_tier.input_cost == 5.04 + assert second_tier.output_cost == 3.36 + assert second_tier.total_cost == 8.4 + # ==================== usage.py (recording & stats — SQLite dynamic data) ==================== diff --git a/tests/server/routes/test_provider_model_bootstrap.py b/tests/server/routes/test_provider_model_bootstrap.py index 8920a503e..2a5cc8dd1 100644 --- a/tests/server/routes/test_provider_model_bootstrap.py +++ b/tests/server/routes/test_provider_model_bootstrap.py @@ -22,35 +22,41 @@ async def test_router_refresh_discovers_models_and_uses_first_tier_price( payload = { "code": 0, "msg": "ok", - "data": [ - { - "model_name": "MiniMax-M3", - "input_price": 99, - "output_price": 99, - "price_tiers": [ - { - "max_input_tokens": 512000, - "input_price": 4.2, - "output_price": 16.8, - }, - { - "max_input_tokens": None, - "input_price": 8.4, - "output_price": 33.6, - }, - ], - }, - { - "model_name": "Qwen3.8-Max", - "input_price": 12, - "output_price": 36, - }, - { - "model_name": "Router-New", - "input_price": 3, - "output_price": 7, - }, - ], + "data": { + "total": 3, + "page": 1, + "pageSize": 100, + "list": [ + { + "modelName": "MiniMax-M3", + "inputPrice": 99, + "outputPrice": 99, + "priceVersion": "2026061601", + "priceTiers": [ + { + "maxInputTokens": 512000, + "inputPrice": 4.2, + "outputPrice": 16.8, + }, + { + "maxInputTokens": None, + "inputPrice": 8.4, + "outputPrice": 33.6, + }, + ], + }, + { + "modelName": "Qwen3.8-Max", + "inputPrice": 12, + "outputPrice": 36, + }, + { + "modelName": "Router-New", + "inputPrice": 3, + "outputPrice": 7, + }, + ], + }, } requests = [] @@ -64,8 +70,8 @@ async def __aenter__(self): async def __aexit__(self, *_args): return False - async def get(self, url, headers=None): - requests.append((url, headers)) + async def get(self, url, headers=None, cookies=None, params=None): + requests.append((url, headers, cookies, params)) return httpx.Response( 200, request=httpx.Request("GET", url), @@ -78,7 +84,10 @@ async def get(self, url, headers=None): provider_id=provider.id, api_key="test-key", base_url="https://router.example/v1", - custom_settings={"trust_env": False}, + custom_settings={ + "trust_env": False, + "model_catalog_url": "https://catalog.example/api/console/common/models", + }, )) assert await provider.refresh_models(force=True) is True @@ -88,7 +97,21 @@ async def get(self, url, headers=None): "input": 4.2, "output": 16.8, "currency": "CNY", + "price_tiers": [ + { + "max_input_tokens": 512000, + "input_price": 4.2, + "output_price": 16.8, + }, + { + "max_input_tokens": None, + "input_price": 8.4, + "output_price": 33.6, + }, + ], + "price_version": "2026061601", } + assert models["minimax-m3"].name == "MiniMax-M3" assert models["qwen3.8-max"].pricing == { "input": 12.0, "output": 36.0, @@ -99,8 +122,25 @@ async def get(self, url, headers=None): assert definitions["qwen3.8-max"].capabilities.supports_vision is True assert definitions["qwen3.8-max"].limits.context_window == 1000000 assert definitions["Router-New"].pricing.input == 3.0 - assert requests[0][0] == "https://router.example/v1/models" + assert requests[0][0] == "https://catalog.example/api/console/common/models" assert requests[0][1]["Authorization"] == "Bearer test-key" + assert requests[0][3] == {"page": 1, "pageSize": 100} + + def test_model_catalog_fallback_stays_on_configured_router_origin(self): + provider = ThreatBookCnLLMProvider() + provider.configure(ProviderConfig( + provider_id=provider.id, + api_key="test-key", + base_url="https://chat-test.example/v1", + custom_settings={ + "model_catalog_url": "https://router-prod.example/api/console/common/models" + }, + )) + + assert provider._model_catalog_urls() == [ + "https://router-prod.example/api/console/common/models", + "https://router-prod.example/v1/models", + ] @pytest.mark.asyncio async def test_router_refresh_failure_keeps_config_fallback( @@ -116,7 +156,7 @@ async def __aenter__(self): async def __aexit__(self, *_args): return False - async def get(self, url, headers=None): + async def get(self, url, headers=None, cookies=None, params=None): return httpx.Response( 404, request=httpx.Request("GET", url), @@ -142,16 +182,15 @@ async def get(self, url, headers=None): assert await provider.refresh_models(force=True) is False models = {model.id: model for model in provider.get_models()} assert "fallback-model" not in models - assert models["minimax-m3"].pricing == { - "input": 4.2, - "output": 16.8, - "currency": "CNY", - } + assert models["minimax-m3"].pricing["input"] == 4.2 + assert models["minimax-m3"].pricing["output"] == 16.8 + assert len(models["minimax-m3"].pricing["price_tiers"]) == 2 assert models["minimax-m2.5"].pricing["output"] == 8.42 assert models["qwen3.8-max"].pricing == { "input": 12.0, "output": 36.0, "currency": "CNY", + "price_version": "2026080301", } @pytest.mark.asyncio @@ -159,6 +198,10 @@ async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): result = await provider_routes.get_provider_catalog() providers = {provider["id"]: provider for provider in result["providers"]} + assert providers["threatbook-cn-llm"]["default_model_catalog_url"] == ( + "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" + ) + for provider_id in ("threatbook-cn-llm", "threatbook-io-llm"): models = { model["id"]: model @@ -178,6 +221,35 @@ async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): ), "cache_write": None, "currency": "CNY", + "price_tiers": ( + [ + { + "max_input_tokens": 100000, + "input_price": 1.0, + "output_price": 2.0, + }, + { + "max_input_tokens": 10000000, + "input_price": 2.0, + "output_price": 4.0, + }, + { + "max_input_tokens": 100000000, + "input_price": 1.0, + "output_price": 2.0, + }, + { + "max_input_tokens": None, + "input_price": 3.0, + "output_price": 6.0, + }, + ] if provider_id == "threatbook-cn-llm" else None + ), + "price_version": ( + "2026081405" + if provider_id == "threatbook-cn-llm" + else None + ), } @pytest.mark.asyncio @@ -193,7 +265,10 @@ async def test_set_provider_credentials_bootstraps_kimi_k26_from_catalog( result = await provider_routes.set_provider_credentials( "threatbook-cn-llm", - provider_routes.ProviderCredentialRequest(api_key="tb-key"), + provider_routes.ProviderCredentialRequest( + api_key="tb-key", + model_catalog_url="https://router-prod.example/api/console/common/models", + ), ) assert result["success"] is True @@ -201,11 +276,89 @@ async def test_set_provider_credentials_bootstraps_kimi_k26_from_catalog( raw = ConfigWriter.get_provider_raw("threatbook-cn-llm") assert raw is not None assert "kimi-k2.7-code" in raw["models"] - assert raw["models"]["kimi-k2.7-code"]["name"] == "kimi-k2.7-code" + assert raw["models"]["kimi-k2.7-code"]["name"] == "Kimi-K2.7-Code" assert "kimi-k2.6" in raw["models"] - assert raw["models"]["kimi-k2.6"]["name"] == "kimi-k2.6" + assert raw["models"]["kimi-k2.6"]["name"] == "Kimi-K2.6" + assert raw["options"]["model_catalog_url"] == ( + "https://router-prod.example/api/console/common/models" + ) fake_secrets.set.assert_called_once_with("threatbook-cn-llm_llm_key", "tb-key") runtime_provider.configure.assert_called_once() + runtime_config = runtime_provider.configure.call_args.args[0] + assert runtime_config.custom_settings["model_catalog_url"] == ( + "https://router-prod.example/api/console/common/models" + ) + + @pytest.mark.asyncio + async def test_model_catalog_url_rejects_non_http_url_before_writing_secret( + self, monkeypatch: pytest.MonkeyPatch + ): + fake_secrets = MagicMock() + monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) + + with pytest.raises(provider_routes.HTTPException) as exc_info: + await provider_routes.set_provider_credentials( + "threatbook-cn-llm", + provider_routes.ProviderCredentialRequest( + api_key="tb-key", + model_catalog_url="file:///tmp/models.json", + ), + ) + + assert exc_info.value.status_code == 400 + fake_secrets.set.assert_not_called() + + @pytest.mark.asyncio + async def test_existing_provider_model_catalog_url_is_updated_and_returned( + self, monkeypatch: pytest.MonkeyPatch + ): + ConfigWriter.add_provider( + "threatbook-cn-llm", + ConfigWriter.build_provider_config( + "threatbook-cn-llm", + base_url="https://llm.threatbook.cn/v1", + models={}, + extra_options={ + "model_catalog_url": "https://router-test.example/api/console/common/models" + }, + ), + ) + fake_secrets = MagicMock() + fake_secrets.get.return_value = "existing-key" + runtime_provider = MagicMock() + runtime_provider._config = ProviderConfig( + provider_id="threatbook-cn-llm", + api_key="existing-key", + base_url="https://llm.threatbook.cn/v1", + custom_settings={ + "model_catalog_url": "https://router-test.example/api/console/common/models" + }, + ) + monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) + monkeypatch.setattr(provider_routes.Provider, "get", lambda _provider_id: runtime_provider) + + await provider_routes.set_provider_credentials( + "threatbook-cn-llm", + provider_routes.ProviderCredentialRequest( + model_catalog_url="https://router-prod.example/api/console/common/models" + ), + ) + + raw = ConfigWriter.get_provider_raw("threatbook-cn-llm") + assert raw["options"]["model_catalog_url"] == ( + "https://router-prod.example/api/console/common/models" + ) + runtime_config = runtime_provider.configure.call_args.args[0] + assert runtime_config.custom_settings["model_catalog_url"] == ( + "https://router-prod.example/api/console/common/models" + ) + response = provider_routes._load_llm_provider_credentials( + "threatbook-cn-llm" + ) + assert response.model_catalog_url == ( + "https://router-prod.example/api/console/common/models" + ) def test_sync_catalog_models_to_config_backfills_missing_kimi_k26(self): existing_models = { @@ -231,4 +384,4 @@ def test_sync_catalog_models_to_config_backfills_missing_kimi_k26(self): assert added == 1 assert raw is not None assert "kimi-k2.6" in raw["models"] - assert raw["models"]["kimi-k2.6"]["name"] == "kimi-k2.6" + assert raw["models"]["kimi-k2.6"]["name"] == "Kimi-K2.6" diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index 937fdc6ef..fff0520ee 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -73,7 +73,8 @@ "deprecated": "Deprecated", "testingModel": "Testing", "available": "Available", - "free": "Free" + "free": "Free", + "tieredPricing": "{{count}} tiers" }, "emptyDetail": { "addFirst": "Start adding a provider", @@ -105,6 +106,8 @@ "baseUrl": "Base URL", "baseUrlOptional": "(optional, leave empty for default)", "baseUrlRequired": "Please enter Base URL", + "modelCatalogUrl": "Model Catalog URL", + "modelCatalogUrlHint": "Syncs Router model names, prices, and pricing tiers independently from the Chat Base URL; replace it when promoting environments.", "apiKey": "API Key", "ollamaNoKey": "(Ollama usually doesn't need this)", "apiKeyOptional": "(optional, leave empty for no-auth gateways)", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index bf9dd91d3..37cb204d8 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -73,7 +73,8 @@ "deprecated": "已弃用", "testingModel": "测试中", "available": "可用", - "free": "免费" + "free": "免费", + "tieredPricing": "阶梯 {{count}} 档" }, "emptyDetail": { "addFirst": "开始添加模型供应商", @@ -105,6 +106,8 @@ "baseUrl": "Base URL", "baseUrlOptional": "(可选,留空用默认)", "baseUrlRequired": "请填写 Base URL", + "modelCatalogUrl": "模型目录 URL", + "modelCatalogUrlHint": "用于同步 Router 模型名称、价格和阶梯计费;与 Chat Base URL 相互独立,上线时可直接替换。", "apiKey": "API Key", "ollamaNoKey": "(Ollama 通常不需要)", "apiKeyOptional": "(可选,无鉴权网关可留空)", diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index cabbe5667..3939a5c95 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -52,6 +52,8 @@ vi.mock('react-i18next', () => ({ 'form.selectProvider': 'Select Provider...', 'form.baseUrlOptional': '(optional, leave empty for default)', 'form.baseUrlRequired': 'Please enter Base URL', + 'form.modelCatalogUrl': 'Model Catalog URL', + 'form.modelCatalogUrlHint': 'Independent Router catalog endpoint', 'form.apiKeyOptional': '(optional, leave empty for no-auth gateways)', 'form.apiKeyOptionalHint': 'Leave empty for no-auth gateway', 'form.apiKeyKeepExisting': 'Leave blank to keep the existing API key', @@ -235,6 +237,55 @@ describe('ModelPage add provider dialog', () => { }); }); }); + + it('submits a configurable ThreatBook model catalog URL separately from Base URL', async () => { + const user = userEvent.setup(); + mocks.catalogList.mockResolvedValue({ + data: { + providers: [ + { + id: 'threatbook-cn-llm', + name: 'ThreatBook-cn-llm', + description: 'ThreatBook Router', + credential_schemas: [{ + auth_method: 'api_key', + fields: [{ name: 'api_key', label: 'API Key', type: 'secret', required: true, placeholder: 'tb-key' }], + }], + env_vars: [], + default_base_url: 'https://llm.threatbook.cn/v1', + default_model_catalog_url: 'https://flocks-router-test.threatbook-inc.cn/api/console/common/models', + model_count: 0, + models: [], + }, + ], + }, + }); + mocks.setCredentials.mockResolvedValue({ data: { success: true } }); + + renderWithRouter(); + await user.click(screen.getByRole('button', { name: 'Add Provider' })); + await user.click(await screen.findByRole('button', { name: 'Select Provider...' })); + await user.click(await screen.findByRole('button', { name: /ThreatBook-cn-llm/i })); + + const catalogUrlInput = screen.getByDisplayValue( + 'https://flocks-router-test.threatbook-inc.cn/api/console/common/models', + ); + await user.clear(catalogUrlInput); + await user.type(catalogUrlInput, 'https://router-prod.example/api/console/common/models'); + await user.type(screen.getByPlaceholderText('tb-key'), 'tb-secret'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mocks.setCredentials).toHaveBeenCalledWith( + 'threatbook-cn-llm', + expect.objectContaining({ + api_key: 'tb-secret', + base_url: 'https://llm.threatbook.cn/v1', + model_catalog_url: 'https://router-prod.example/api/console/common/models', + }), + ); + }); + }); }); describe('ModelPage configure provider dialog', () => { @@ -455,7 +506,18 @@ describe('ModelPage default model selector', () => { modalities: { input: ['text', 'image'], output: ['text'] }, }, limits: { context_window: 200000, max_output_tokens: 8192 }, - pricing: { input: 1.25, output: 5, cache_read: 0.25, unit: 1000000, currency: 'USD' }, + pricing: { + input: 1.25, + output: 5, + cache_read: 0.25, + unit: 1000000, + currency: 'USD', + price_version: '2026082601', + price_tiers: [ + { max_input_tokens: 512000, input_price: 1.25, output_price: 5 }, + { max_input_tokens: null, input_price: 2.5, output_price: 10 }, + ], + }, }, ]; @@ -513,6 +575,8 @@ describe('ModelPage default model selector', () => { expect(tooltip).toHaveTextContent(/1\.25/); expect(tooltip).toHaveTextContent(/\b5(?:\.0+)?\b/); expect(tooltip).toHaveTextContent('$1.25/$5/$0.25/M'); + expect(tooltip).toHaveTextContent('≤ 512,000: $1.25/$5/M'); + expect(tooltip).toHaveTextContent('> 512,000: $2.5/$10/M'); expect(tooltip).toHaveTextContent(/USD|\$/); }); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 5fcac4d96..b16a7096f 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -24,7 +24,7 @@ import { customAPI, modelSettingsAPI, catalogAPI, defaultModelAPI, } from '@/api/provider'; import { hasPendingProviderCredentialChanges } from './providerCredentialUtils'; -import { formatPricingPerMillion, isPricingFree } from '@/utils/modelPricing'; +import { formatPriceTiers, formatPricingPerMillion, isPricingFree } from '@/utils/modelPricing'; import { convertCurrencyAmount, formatTokenMillions, @@ -36,6 +36,7 @@ import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, CustomModelCreate, ProviderCredentialInput, FallbackModelRef, + PriceConfigV2, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -1033,6 +1034,32 @@ function ProviderDetail({ // ==================== Model Card (V2) ==================== +function PricingSummary({ pricing }: { + pricing: PriceConfigV2 | NonNullable; +}) { + const { t } = useTranslation('model'); + const tierCount = pricing.price_tiers?.length ?? 0; + const tierDetails = tierCount > 0 ? formatPriceTiers(pricing) : ''; + const title = [ + pricing.price_version ? `v${pricing.price_version}` : '', + tierDetails, + ].filter(Boolean).join('\n'); + + return ( + + {formatPricingPerMillion(pricing)} + {tierCount > 0 && ( + + {t('status.tieredPricing', { count: tierCount })} + + )} + + ); +} + function ModelCard({ model, enabled, testStatus, onOpenDetail, onTestModel, onToggle, onDelete }: { model: ModelDefinitionV2; enabled: boolean; @@ -1099,7 +1126,7 @@ function ModelCard({ model, enabled, testStatus, onOpenDetail, onTestModel, onTo {contextK && {contextK}} {pricing && !isPricingFree(pricing) && ( - {formatPricingPerMillion(pricing)} + )} {pricing && isPricingFree(pricing) && ( {t('status.free')} @@ -1189,6 +1216,7 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { const [apiKey, setApiKey] = useState(''); const [showApiKey, setShowApiKey] = useState(false); const [baseUrl, setBaseUrl] = useState(''); + const [modelCatalogUrl, setModelCatalogUrl] = useState(''); const [description, setDescription] = useState(''); const [providerName, setProviderName] = useState(''); const [azureDeploymentName, setAzureDeploymentName] = useState(''); @@ -1273,6 +1301,7 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { if (provider) { setDisplayName(provider.name); setBaseUrl(provider.default_base_url || ''); + setModelCatalogUrl(provider.default_model_catalog_url || ''); setApiKey(''); setDescription(provider.description || ''); setSelectedModelIds(new Set(provider.models.map(m => m.id))); @@ -1317,6 +1346,9 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { await providerAPI.setCredentials(selectedCatalogId, { api_key: apiKey.trim(), base_url: baseUrl.trim() || undefined, + model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' + ? modelCatalogUrl.trim() + : undefined, provider_name: selectedCatalogId === 'openai-compatible' && providerName.trim() ? providerName.trim() : undefined, }); const azureModelId = isAzureProviderId(selectedCatalogId) ? azureDeploymentName.trim() : ''; @@ -1371,6 +1403,9 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { await providerAPI.setCredentials(selectedCatalogId, { api_key: apiKey.trim() || 'not-needed', base_url: baseUrl.trim() || undefined, + model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' + ? modelCatalogUrl.trim() + : undefined, }); if (selectedCatalog) { const unselected = selectedCatalog.models.filter(m => !selectedModelIds.has(m.id)).map(m => m.id); @@ -1694,6 +1729,23 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { /> + {selectedCatalogId === 'threatbook-cn-llm' && ( +
+ + setModelCatalogUrl(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder={selectedCatalog.default_model_catalog_url || 'https://router.example.com/api/console/common/models'} + /> +

{t('form.modelCatalogUrlHint')}

+
+ )} +
+ {provider.id === 'threatbook-cn-llm' && ( +
+ + setModelCatalogUrl(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder="https://router.example.com/api/console/common/models" + /> +

{t('form.modelCatalogUrlHint')}

+
+ )} + {/* API Key */}
- {/* 价格 — 可编辑 */} + {/* Router prices are authoritative; other providers remain editable. */}
+ {isRouterManagedPricing && ( +
+ {t('form.routerPricingManaged')} + {model.pricing?.price_version && ( + + {t('form.priceVersion')}: {model.pricing.price_version} + + )} +
+ )}
- setInputPrice(e.target.value)} className={inputCls} /> + setInputPrice(e.target.value)} + className={isRouterManagedPricing ? inputClsReadOnly : inputCls} + />
- setOutputPrice(e.target.value)} className={inputCls} /> + setOutputPrice(e.target.value)} + className={isRouterManagedPricing ? inputClsReadOnly : inputCls} + />
@@ -3065,14 +3111,20 @@ function ModelDetailSheet({ type="number" step="0.01" value={cacheReadPrice} + readOnly={isRouterManagedPricing} onChange={e => setCacheReadPrice(e.target.value)} - className={inputCls} + className={isRouterManagedPricing ? inputClsReadOnly : inputCls} placeholder="—" />
- handleCurrencyChange(e.target.value)} + className={isRouterManagedPricing ? inputClsReadOnly : inputCls} + > {currency !== 'USD' && currency !== 'CNY' && ( )} @@ -3081,6 +3133,25 @@ function ModelDetailSheet({
+ {priceTiers.length > 0 && ( +
+
+ {t('form.inputTokenRange')} + {t('form.inputUnitPrice')} + {t('form.outputUnitPrice')} +
+ {priceTiers.map((tier, index) => ( +
+ {formatTierTokenRange(priceTiers, index)} + {priceSymbol}{tier.input_price} + {priceSymbol}{tier.output_price} +
+ ))} +
+ )}
{/* 启用此模型 — 可编辑 */} From 3e82e50d0fd53ecf0b31be4638ec65a82810306e Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Wed, 26 Aug 2026 17:22:12 +0800 Subject: [PATCH 5/6] fix(provider): isolate Router catalog authentication --- flocks/provider/sdk/threatbook.py | 77 ++++++++---- flocks/server/routes/provider.py | 79 ++++++++++++ .../routes/test_provider_model_bootstrap.py | 96 ++++++++++++++- webui/src/locales/en-US/model.json | 4 + webui/src/locales/zh-CN/model.json | 4 + webui/src/pages/Model/index.tsx | 113 +++++++++++++----- .../Model/providerCredentialUtils.test.ts | 17 +++ .../pages/Model/providerCredentialUtils.ts | 4 +- webui/src/types/index.ts | 3 + 9 files changed, 339 insertions(+), 58 deletions(-) diff --git a/flocks/provider/sdk/threatbook.py b/flocks/provider/sdk/threatbook.py index 18b47a619..e8791f76b 100644 --- a/flocks/provider/sdk/threatbook.py +++ b/flocks/provider/sdk/threatbook.py @@ -7,10 +7,11 @@ """ import asyncio +import hashlib import os import time from typing import Any, Optional -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import urlsplit import httpx @@ -35,6 +36,9 @@ class ThreatBookCnLLMProvider(OpenAIBaseProvider): CATALOG_ID = "threatbook-cn-llm" MODEL_CATALOG_CACHE_TTL_SECONDS = 60.0 MODEL_CATALOG_TIMEOUT_SECONDS = 5.0 + MODEL_CATALOG_SESSION_SECRET_PREFIX = ( + "threatbook-cn-llm_model_catalog_session_" + ) def __init__(self): super().__init__(provider_id="threatbook-cn-llm", name="ThreatBook-cn-llm") @@ -81,26 +85,52 @@ def _get_model_definition_source_models(self) -> list[ModelInfo]: return self.get_models() def _model_catalog_urls(self) -> list[str]: - """Return catalog sources in consistency-preferred order. - - Console's common-model endpoint is the fee-details page's exact data - source. It currently requires a Passport ``session_token`` cookie, so - the API-key-protected ``/v1/models`` endpoint remains the runtime - fallback for ordinary Flocks installations. Both response shapes are - parsed identically below. - """ + """Return only the explicitly configured Router catalog endpoint.""" custom_settings = getattr(self._config, "custom_settings", None) or {} configured_url = ( custom_settings.get("model_catalog_url") if isinstance(custom_settings, dict) else None ) or os.getenv("THREATBOOK_CN_LLM_MODEL_CATALOG_URL") catalog_url = configured_url or self.DEFAULT_MODEL_CATALOG_URL - urls = [catalog_url] - if "/api/console/" in urlsplit(catalog_url).path: - parsed = urlsplit(catalog_url) - origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) - urls.append(f"{origin}/v1/models") - return list(dict.fromkeys(urls)) + return [catalog_url] + + @classmethod + def supports_model_catalog_session(cls, catalog_url: str) -> bool: + """Only attach a Console session to trusted HTTPS Router URLs.""" + parsed = urlsplit(catalog_url) + hostname = (parsed.hostname or "").lower() + return ( + parsed.scheme == "https" + and ( + hostname == "threatbook-inc.cn" + or hostname.endswith(".threatbook-inc.cn") + ) + and parsed.path.startswith("/api/console/") + ) + + @classmethod + def model_catalog_session_secret_id(cls, catalog_url: str) -> str: + """Build an environment-bound secret ID from the catalog origin.""" + parsed = urlsplit(catalog_url) + origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" + origin_hash = hashlib.sha256(origin.encode("utf-8")).hexdigest()[:16] + return f"{cls.MODEL_CATALOG_SESSION_SECRET_PREFIX}{origin_hash}" + + @classmethod + def get_model_catalog_session_token(cls, catalog_url: str) -> Optional[str]: + """Resolve the session for this Router environment without exposing it.""" + env_token = os.getenv("THREATBOOK_CN_LLM_CONSOLE_SESSION_TOKEN") + if env_token and env_token.strip(): + return env_token.strip() + try: + from flocks.security import get_secret_manager + + token = get_secret_manager().get( + cls.model_catalog_session_secret_id(catalog_url) + ) + except Exception: + return None + return token.strip() if isinstance(token, str) and token.strip() else None @staticmethod def _positive_float_env( @@ -299,7 +329,7 @@ async def _fetch_catalog_rows( headers: dict[str, str], cookies: Optional[dict[str, str]], ) -> list[Any]: - """Fetch either Console's paginated shape or ``/v1/models``.""" + """Fetch Router Console's model catalog, including paginated results.""" page = 1 page_size = 100 rows: list[Any] = [] @@ -384,12 +414,6 @@ async def refresh_models(self, force: bool = False) -> bool: self.MODEL_CATALOG_TIMEOUT_SECONDS, ) headers: dict[str, str] = {"Accept": "application/json"} - api_key = self._config.api_key if self._config else self._api_key - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - session_token = os.getenv("THREATBOOK_CN_LLM_CONSOLE_SESSION_TOKEN") - cookies = {"session_token": session_token} if session_token else None models = None successful_url = None @@ -401,6 +425,15 @@ async def refresh_models(self, force: bool = False) -> bool: ) as client: for models_url in models_urls: try: + session_token = self.get_model_catalog_session_token( + models_url + ) + cookies = ( + {"session_token": session_token} + if session_token + and self.supports_model_catalog_session(models_url) + else None + ) rows = await self._fetch_catalog_rows( client, models_url, diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index e8f408bfb..3dc4bfd24 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -1687,6 +1687,10 @@ class ProviderCredentialRequest(BaseModel): None, description="Model catalog URL, independent from the provider chat Base URL", ) + model_catalog_session_token: Optional[str] = Field( + None, + description="Router Console session token used only for model catalog sync", + ) username: Optional[str] = Field(None, description="Optional username for API services") fields: Optional[Dict[str, Optional[str]]] = Field(None, description="Dynamic service credential fields") provider_name: Optional[str] = Field(None, description="Display name for the provider") @@ -1701,6 +1705,8 @@ class ProviderCredentialResponse(BaseModel): secret_masked: Optional[str] = None base_url: Optional[str] = None model_catalog_url: Optional[str] = None + model_catalog_session_token_masked: Optional[str] = None + has_model_catalog_session: bool = False username: Optional[str] = None fields: Optional[Dict[str, Optional[str]]] = None secret_ids: Optional[Dict[str, str]] = None @@ -1747,6 +1753,19 @@ def _load_llm_provider_credentials( except Exception: pass + model_catalog_session_token = None + if provider_id == "threatbook-cn-llm" and model_catalog_url: + try: + from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider + + model_catalog_session_token = ( + ThreatBookCnLLMProvider.get_model_catalog_session_token( + model_catalog_url + ) + ) + except Exception: + pass + is_placeholder = _is_placeholder_api_key(api_key) ui_api_key = None if is_placeholder else api_key @@ -1756,6 +1775,12 @@ def _load_llm_provider_credentials( api_key_masked=SecretManager.mask(ui_api_key) if ui_api_key else None, base_url=base_url, model_catalog_url=model_catalog_url, + model_catalog_session_token_masked=( + SecretManager.mask(model_catalog_session_token) + if model_catalog_session_token + else None + ), + has_model_catalog_session=bool(model_catalog_session_token), has_credential=bool(api_key), ) @@ -1843,6 +1868,7 @@ async def set_provider_credentials( - api_key → .secret.json - base_url → flocks.json provider.{id}.options.baseURL - model_catalog_url → flocks.json provider.{id}.options.model_catalog_url + - model_catalog_session_token → environment-bound local secret storage - Ensures provider entry exists in flocks.json - Configures provider runtime immediately """ @@ -1905,6 +1931,42 @@ async def set_provider_credentials( detail="Model catalog URL must be an absolute HTTP(S) URL", ) + model_catalog_session_was_set = ( + "model_catalog_session_token" in request.model_fields_set + ) + effective_model_catalog_session = ( + (request.model_catalog_session_token or "").strip() + if model_catalog_session_was_set + else None + ) + catalog_session_secret_id = None + if model_catalog_session_was_set: + if provider_id != "threatbook-cn-llm": + raise HTTPException( + status_code=400, + detail="Model catalog session is only supported for ThreatBook-cn-llm", + ) + session_catalog_url = effective_model_catalog_url + if not model_catalog_url_was_set: + current = _load_llm_provider_credentials(provider_id) + session_catalog_url = current.model_catalog_url + from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider + + if not session_catalog_url or not ThreatBookCnLLMProvider.supports_model_catalog_session( + session_catalog_url + ): + raise HTTPException( + status_code=400, + detail=( + "Model catalog session requires an HTTPS ThreatBook Console catalog URL" + ), + ) + catalog_session_secret_id = ( + ThreatBookCnLLMProvider.model_catalog_session_secret_id( + session_catalog_url + ) + ) + # 1. Save API key to .secret.json using _llm_key convention for LLM providers if preserve_existing_secret: log.info("provider.credentials.preserved", { @@ -1928,6 +1990,15 @@ async def set_provider_credentials( "base_url": request.base_url, }) + if model_catalog_session_was_set and catalog_session_secret_id: + if effective_model_catalog_session: + secrets.set( + catalog_session_secret_id, + effective_model_catalog_session, + ) + else: + secrets.delete(catalog_session_secret_id) + # 2. Ensure provider entry exists in flocks.json and update base_url / name raw_provider = ConfigWriter.get_provider_raw(provider_id) if raw_provider: @@ -2086,6 +2157,14 @@ async def delete_provider_credentials( deleted_secret = secrets.delete(f"{provider_id}_api_key") or deleted_secret # Also clean up legacy base_url entries secrets.delete(f"{provider_id}_base_url") + if provider_id == "threatbook-cn-llm": + from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider + + for stored_secret_id in secrets.list(): + if stored_secret_id.startswith( + ThreatBookCnLLMProvider.MODEL_CATALOG_SESSION_SECRET_PREFIX + ): + deleted_secret = secrets.delete(stored_secret_id) or deleted_secret if not removed_config and not deleted_secret: raise HTTPException(status_code=404, detail="No credentials found for this provider") diff --git a/tests/server/routes/test_provider_model_bootstrap.py b/tests/server/routes/test_provider_model_bootstrap.py index 6d898c022..e1e932790 100644 --- a/tests/server/routes/test_provider_model_bootstrap.py +++ b/tests/server/routes/test_provider_model_bootstrap.py @@ -96,6 +96,11 @@ async def get(self, url, headers=None, cookies=None, params=None): ) monkeypatch.setattr(threatbook.httpx, "AsyncClient", FakeClient) + monkeypatch.setattr( + ThreatBookCnLLMProvider, + "get_model_catalog_session_token", + classmethod(lambda cls, _url: "test-console-session"), + ) provider = ThreatBookCnLLMProvider() provider.configure(ProviderConfig( provider_id=provider.id, @@ -103,7 +108,7 @@ async def get(self, url, headers=None, cookies=None, params=None): base_url="https://router.example/v1", custom_settings={ "trust_env": False, - "model_catalog_url": "https://catalog.example/api/console/common/models", + "model_catalog_url": "https://flocks-router-test.threatbook-inc.cn/api/console/common/models", }, )) @@ -139,11 +144,14 @@ async def get(self, url, headers=None, cookies=None, params=None): assert definitions["qwen3.8-max"].capabilities.supports_vision is True assert definitions["qwen3.8-max"].limits.context_window == 1000000 assert definitions["Router-New"].pricing.input == 3.0 - assert requests[0][0] == "https://catalog.example/api/console/common/models" - assert requests[0][1]["Authorization"] == "Bearer test-key" + assert requests[0][0] == ( + "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" + ) + assert requests[0][1] == {"Accept": "application/json"} + assert requests[0][2] == {"session_token": "test-console-session"} assert requests[0][3] == {"page": 1, "pageSize": 100} - def test_model_catalog_fallback_stays_on_configured_router_origin(self): + def test_model_catalog_uses_only_explicitly_configured_url(self): provider = ThreatBookCnLLMProvider() provider.configure(ProviderConfig( provider_id=provider.id, @@ -156,9 +164,28 @@ def test_model_catalog_fallback_stays_on_configured_router_origin(self): assert provider._model_catalog_urls() == [ "https://router-prod.example/api/console/common/models", - "https://router-prod.example/v1/models", ] + def test_model_catalog_session_is_bound_to_router_environment(self): + test_url = ( + "https://flocks-router-test.threatbook-inc.cn" + "/api/console/common/models" + ) + prod_url = ( + "https://flocks-router.threatbook-inc.cn" + "/api/console/common/models" + ) + + assert ThreatBookCnLLMProvider.supports_model_catalog_session(test_url) + assert ThreatBookCnLLMProvider.supports_model_catalog_session(prod_url) + assert ( + ThreatBookCnLLMProvider.model_catalog_session_secret_id(test_url) + != ThreatBookCnLLMProvider.model_catalog_session_secret_id(prod_url) + ) + assert not ThreatBookCnLLMProvider.supports_model_catalog_session( + "https://example.com/api/console/common/models" + ) + @pytest.mark.asyncio async def test_router_refresh_failure_keeps_config_fallback( self, monkeypatch: pytest.MonkeyPatch @@ -325,6 +352,65 @@ async def test_model_catalog_url_rejects_non_http_url_before_writing_secret( assert exc_info.value.status_code == 400 fake_secrets.set.assert_not_called() + @pytest.mark.asyncio + async def test_model_catalog_session_is_stored_separately_and_masked( + self, monkeypatch: pytest.MonkeyPatch + ): + catalog_url = ( + "https://flocks-router-test.threatbook-inc.cn" + "/api/console/common/models" + ) + ConfigWriter.add_provider( + "threatbook-cn-llm", + ConfigWriter.build_provider_config( + "threatbook-cn-llm", + base_url="https://flocks-router-test.threatbook-inc.cn/v1", + models={}, + extra_options={"model_catalog_url": catalog_url}, + ), + ) + stored = {"threatbook-cn-llm_llm_key": "existing-api-key"} + fake_secrets = MagicMock() + fake_secrets.get.side_effect = stored.get + fake_secrets.set.side_effect = lambda secret_id, value: stored.__setitem__( + secret_id, value + ) + runtime_provider = MagicMock() + runtime_provider._config = ProviderConfig( + provider_id="threatbook-cn-llm", + api_key="existing-api-key", + base_url="https://flocks-router-test.threatbook-inc.cn/v1", + custom_settings={"model_catalog_url": catalog_url}, + ) + monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) + monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) + monkeypatch.setattr( + provider_routes.Provider, + "get", + lambda _provider_id: runtime_provider, + ) + + result = await provider_routes.set_provider_credentials( + "threatbook-cn-llm", + provider_routes.ProviderCredentialRequest( + model_catalog_session_token="test-session-token" + ), + ) + + session_secret_id = ( + ThreatBookCnLLMProvider.model_catalog_session_secret_id(catalog_url) + ) + assert result["success"] is True + assert stored[session_secret_id] == "test-session-token" + assert "model_catalog_session_token" not in str( + ConfigWriter.get_provider_raw("threatbook-cn-llm") + ) + response = provider_routes._load_llm_provider_credentials( + "threatbook-cn-llm" + ) + assert response.has_model_catalog_session is True + assert response.model_catalog_session_token_masked == "te***oken" + @pytest.mark.asyncio async def test_existing_provider_model_catalog_url_is_updated_and_returned( self, monkeypatch: pytest.MonkeyPatch diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index 810b815d3..17d6c561f 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -108,6 +108,10 @@ "baseUrlRequired": "Please enter Base URL", "modelCatalogUrl": "Model Catalog URL", "modelCatalogUrlHint": "Syncs Router model names, prices, and pricing tiers independently from the Chat Base URL; replace it when promoting environments.", + "modelCatalogSession": "Model Catalog Session", + "modelCatalogSessionPlaceholder": "Paste the session_token for this Router environment", + "modelCatalogSessionKeepExisting": "Leave blank to keep this environment's session", + "modelCatalogSessionHint": "Used only for this catalog URL and stored separately per test/production site; enter the matching session when switching environments.", "apiKey": "API Key", "ollamaNoKey": "(Ollama usually doesn't need this)", "apiKeyOptional": "(optional, leave empty for no-auth gateways)", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index 5f0bb1a11..75d4469d9 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -108,6 +108,10 @@ "baseUrlRequired": "请填写 Base URL", "modelCatalogUrl": "模型目录 URL", "modelCatalogUrlHint": "用于同步 Router 模型名称、价格和阶梯计费;与 Chat Base URL 相互独立,上线时可直接替换。", + "modelCatalogSession": "模型目录会话", + "modelCatalogSessionPlaceholder": "粘贴当前 Router 环境的 session_token", + "modelCatalogSessionKeepExisting": "留空以保留当前环境的目录会话", + "modelCatalogSessionHint": "仅用于访问该目录 URL,并按测试/生产站点隔离保存;切换环境时需填写对应环境的会话。", "apiKey": "API Key", "ollamaNoKey": "(Ollama 通常不需要)", "apiKeyOptional": "(可选,无鉴权网关可留空)", diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index b025a364e..df03d9ee3 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -1236,6 +1236,7 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { const [showApiKey, setShowApiKey] = useState(false); const [baseUrl, setBaseUrl] = useState(''); const [modelCatalogUrl, setModelCatalogUrl] = useState(''); + const [modelCatalogSessionToken, setModelCatalogSessionToken] = useState(''); const [description, setDescription] = useState(''); const [providerName, setProviderName] = useState(''); const [azureDeploymentName, setAzureDeploymentName] = useState(''); @@ -1321,6 +1322,7 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { setDisplayName(provider.name); setBaseUrl(provider.default_base_url || ''); setModelCatalogUrl(provider.default_model_catalog_url || ''); + setModelCatalogSessionToken(''); setApiKey(''); setDescription(provider.description || ''); setSelectedModelIds(new Set(provider.models.map(m => m.id))); @@ -1368,6 +1370,9 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' ? modelCatalogUrl.trim() : undefined, + model_catalog_session_token: selectedCatalogId === 'threatbook-cn-llm' + ? (modelCatalogSessionToken.trim() || undefined) + : undefined, provider_name: selectedCatalogId === 'openai-compatible' && providerName.trim() ? providerName.trim() : undefined, }); const azureModelId = isAzureProviderId(selectedCatalogId) ? azureDeploymentName.trim() : ''; @@ -1425,6 +1430,9 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' ? modelCatalogUrl.trim() : undefined, + model_catalog_session_token: selectedCatalogId === 'threatbook-cn-llm' + ? (modelCatalogSessionToken.trim() || undefined) + : undefined, }); if (selectedCatalog) { const unselected = selectedCatalog.models.filter(m => !selectedModelIds.has(m.id)).map(m => m.id); @@ -1749,20 +1757,37 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { {selectedCatalogId === 'threatbook-cn-llm' && ( -
- - setModelCatalogUrl(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder={selectedCatalog.default_model_catalog_url || 'https://router.example.com/api/console/common/models'} - /> -

{t('form.modelCatalogUrlHint')}

-
+ <> +
+ + setModelCatalogUrl(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder={selectedCatalog.default_model_catalog_url || 'https://router.example.com/api/console/common/models'} + /> +

{t('form.modelCatalogUrlHint')}

+
+
+ + setModelCatalogSessionToken(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder={t('form.modelCatalogSessionPlaceholder')} + autoComplete="off" + /> +

{t('form.modelCatalogSessionHint')}

+
+ )}
@@ -2387,9 +2412,11 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const existingKey = existingCredentials?.api_key ?? ''; const existingBaseUrl = existingCredentials?.base_url ?? ''; const existingModelCatalogUrl = existingCredentials?.model_catalog_url ?? ''; + const hasExistingModelCatalogSession = existingCredentials?.has_model_catalog_session ?? false; const [baseUrl, setBaseUrl] = useState(existingCredentials?.base_url ?? ''); const [modelCatalogUrl, setModelCatalogUrl] = useState(existingModelCatalogUrl); + const [modelCatalogSessionToken, setModelCatalogSessionToken] = useState(''); const [apiKey, setApiKey] = useState(existingKey); const [providerName, setProviderName] = useState(provider.name); const [showApiKey, setShowApiKey] = useState(false); @@ -2421,6 +2448,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos setApiKey(existingKey); setBaseUrl(existingBaseUrl); setModelCatalogUrl(existingModelCatalogUrl); + setModelCatalogSessionToken(''); setProviderName(provider.name); }, [existingBaseUrl, existingKey, existingModelCatalogUrl, provider.id, provider.name]); @@ -2481,6 +2509,9 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; + if (provider.id === 'threatbook-cn-llm' && modelCatalogSessionToken.trim()) { + payload.model_catalog_session_token = modelCatalogSessionToken.trim(); + } if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); @@ -2517,8 +2548,8 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos return; } const hasPendingChanges = hasPendingProviderCredentialChanges( - { apiKey: existingKey, baseUrl: existingBaseUrl, modelCatalogUrl: existingModelCatalogUrl }, - { apiKey, baseUrl, modelCatalogUrl }, + { apiKey: existingKey, baseUrl: existingBaseUrl, modelCatalogUrl: existingModelCatalogUrl, modelCatalogSessionToken: '' }, + { apiKey, baseUrl, modelCatalogUrl, modelCatalogSessionToken }, ); const nextApiKey = apiKey.trim(); @@ -2541,6 +2572,9 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; + if (provider.id === 'threatbook-cn-llm' && modelCatalogSessionToken.trim()) { + payload.model_catalog_session_token = modelCatalogSessionToken.trim(); + } if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); } catch (err: any) { @@ -2657,20 +2691,39 @@ ${hasExisting ? '你已有凭证配置,可以更新或测试连接。' : '请
{provider.id === 'threatbook-cn-llm' && ( -
- - setModelCatalogUrl(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder="https://router.example.com/api/console/common/models" - /> -

{t('form.modelCatalogUrlHint')}

-
+ <> +
+ + setModelCatalogUrl(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder="https://router.example.com/api/console/common/models" + /> +

{t('form.modelCatalogUrlHint')}

+
+
+ + setModelCatalogSessionToken(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" + placeholder={hasExistingModelCatalogSession + ? t('form.modelCatalogSessionKeepExisting') + : t('form.modelCatalogSessionPlaceholder')} + autoComplete="off" + /> +

{t('form.modelCatalogSessionHint')}

+
+ )} {/* API Key */} diff --git a/webui/src/pages/Model/providerCredentialUtils.test.ts b/webui/src/pages/Model/providerCredentialUtils.test.ts index 4eb9d70fa..8c6464fa6 100644 --- a/webui/src/pages/Model/providerCredentialUtils.test.ts +++ b/webui/src/pages/Model/providerCredentialUtils.test.ts @@ -38,6 +38,23 @@ describe('hasPendingProviderCredentialChanges', () => { ).toBe(true); }); + it('returns true when a model catalog session is entered', () => { + expect( + hasPendingProviderCredentialChanges( + { + apiKey: 'same-key', + modelCatalogUrl: 'https://router.example/models', + modelCatalogSessionToken: '', + }, + { + apiKey: 'same-key', + modelCatalogUrl: 'https://router.example/models', + modelCatalogSessionToken: 'new-session', + }, + ), + ).toBe(true); + }); + it('ignores whitespace-only differences', () => { expect( hasPendingProviderCredentialChanges( diff --git a/webui/src/pages/Model/providerCredentialUtils.ts b/webui/src/pages/Model/providerCredentialUtils.ts index 5e6944c54..a90f8ad33 100644 --- a/webui/src/pages/Model/providerCredentialUtils.ts +++ b/webui/src/pages/Model/providerCredentialUtils.ts @@ -2,6 +2,7 @@ export interface ProviderCredentialSnapshot { apiKey: string; baseUrl?: string | null; modelCatalogUrl?: string | null; + modelCatalogSessionToken?: string | null; } function normalizeValue(value?: string | null): string { @@ -15,6 +16,7 @@ export function hasPendingProviderCredentialChanges( return ( normalizeValue(existing.apiKey) !== normalizeValue(current.apiKey) || normalizeValue(existing.baseUrl) !== normalizeValue(current.baseUrl) || - normalizeValue(existing.modelCatalogUrl) !== normalizeValue(current.modelCatalogUrl) + normalizeValue(existing.modelCatalogUrl) !== normalizeValue(current.modelCatalogUrl) || + normalizeValue(existing.modelCatalogSessionToken) !== normalizeValue(current.modelCatalogSessionToken) ); } diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index 5bc94525d..4cbf5692d 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -420,6 +420,8 @@ export interface ProviderCredentials { secret_masked?: string | null; base_url?: string | null; model_catalog_url?: string | null; + model_catalog_session_token_masked?: string | null; + has_model_catalog_session?: boolean; username?: string | null; /** Sensitive entries are masked on reads and must not be resubmitted unchanged. */ fields?: Record; @@ -433,6 +435,7 @@ export interface ProviderCredentialInput { secret?: string; base_url?: string; model_catalog_url?: string; + model_catalog_session_token?: string; username?: string; fields?: Record; provider_name?: string; From efba0e96cd9ae417f3ed30d684d025fe23d3abc8 Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Tue, 1 Sep 2026 09:09:59 +0800 Subject: [PATCH 6/6] revert(provider): keep ThreatBook integration key-only --- flocks/provider/catalog.json | 136 +---- flocks/provider/cost_calculator.py | 17 +- flocks/provider/model_catalog.py | 2 - flocks/provider/provider.py | 75 +-- flocks/provider/sdk/threatbook.py | 467 +----------------- flocks/provider/types.py | 13 - flocks/provider/usage_service.py | 20 +- flocks/server/routes/model.py | 17 - flocks/server/routes/provider.py | 150 +----- tests/provider/test_chinese_providers.py | 44 +- tests/provider/test_model_management_p2p3.py | 31 -- .../routes/test_provider_model_bootstrap.py | 411 +-------------- webui/src/api/provider.ts | 3 +- webui/src/locales/en-US/model.json | 14 +- webui/src/locales/zh-CN/model.json | 14 +- webui/src/pages/Model/index.test.tsx | 145 +----- webui/src/pages/Model/index.tsx | 230 +-------- .../Model/providerCredentialUtils.test.ts | 34 -- .../pages/Model/providerCredentialUtils.ts | 6 +- webui/src/types/index.ts | 16 - webui/src/utils/modelPricing.test.ts | 14 +- webui/src/utils/modelPricing.ts | 34 +- 22 files changed, 86 insertions(+), 1807 deletions(-) diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index 1ffa1cc43..665ef4682 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -34,7 +34,6 @@ "description": "ThreatBook China LLM Service (OpenAI-compatible)", "npm": "@ai-sdk/openai-compatible", "default_base_url": "https://llm.threatbook.cn/v1", - "default_model_catalog_url": "https://flocks-router-test.threatbook-inc.cn/api/console/common/models", "credential_schemas": [ { "auth_method": "api_key", @@ -50,12 +49,11 @@ } ], "env_vars": [ - "THREATBOOK_CN_LLM_API_KEY", - "THREATBOOK_CN_LLM_MODEL_CATALOG_URL" + "THREATBOOK_CN_LLM_API_KEY" ], "models": { "deepseek-v4-flash-0731": { - "name": "DeepSeek-V4-Flash-0731", + "name": "deepseek-v4-flash-0731", "family": "deepseek-v4", "capabilities": { "supports_tools": true, @@ -77,34 +75,12 @@ "pricing": { "input": 1.0, "output": 2.0, - "currency": "CNY", - "price_version": "2026081405", - "price_tiers": [ - { - "max_input_tokens": 100000, - "input_price": 1.0, - "output_price": 2.0 - }, - { - "max_input_tokens": 10000000, - "input_price": 2.0, - "output_price": 4.0 - }, - { - "max_input_tokens": 100000000, - "input_price": 1.0, - "output_price": 2.0 - }, - { - "max_input_tokens": null, - "input_price": 3.0, - "output_price": 6.0 - } - ] + "cache_read": 0.2, + "currency": "CNY" } }, "kimi-k2.7-code": { - "name": "Kimi-K2.7-Code", + "name": "kimi-k2.7-code", "family": "kimi-k2.7-code", "capabilities": { "supports_tools": true, @@ -129,12 +105,12 @@ "pricing": { "input": 6.5, "output": 27.0, - "currency": "CNY", - "price_version": "2026072001" + "cache_read": 1.3, + "currency": "CNY" } }, "minimax-m3": { - "name": "MiniMax-M3", + "name": "minimax-m3", "family": "minimax", "capabilities": { "supports_tools": true, @@ -158,23 +134,11 @@ "input": 4.2, "output": 16.8, "currency": "CNY", - "price_version": "2026061601", - "price_tiers": [ - { - "max_input_tokens": 512000, - "input_price": 4.2, - "output_price": 16.8 - }, - { - "max_input_tokens": null, - "input_price": 8.4, - "output_price": 33.6 - } - ] + "note": "≤512k input: ¥4.20/M tokens output: ¥16.80/M tokens; >512k input: ¥8.40/M tokens output: ¥33.60/M tokens" } }, "minimax-m2.7": { - "name": "MiniMax-M2.7", + "name": "minimax-m2.7", "family": "minimax", "capabilities": { "supports_tools": true, @@ -196,12 +160,11 @@ "pricing": { "input": 2.1, "output": 8.4, - "currency": "CNY", - "price_version": "2026061601" + "currency": "CNY" } }, "minimax-m2.5": { - "name": "MiniMax-M2.5", + "name": "minimax-m2.5", "family": "minimax", "capabilities": { "supports_tools": true, @@ -222,9 +185,8 @@ }, "pricing": { "input": 2.1, - "output": 8.42, - "currency": "CNY", - "price_version": "2026081401" + "output": 8.4, + "currency": "CNY" } }, "GLM-5": { @@ -250,12 +212,11 @@ "pricing": { "input": 4.0, "output": 18.0, - "currency": "CNY", - "price_version": "2026061601" + "currency": "CNY" } }, "qwen3.6-plus": { - "name": "Qwen3.6-Plus", + "name": "qwen3.6-plus", "family": "qwen", "capabilities": { "supports_tools": true, @@ -278,12 +239,11 @@ "pricing": { "input": 2.0, "output": 12.0, - "currency": "CNY", - "price_version": "2026061601" + "currency": "CNY" } }, "qwen3-max": { - "name": "Qwen3-Max", + "name": "qwen3-max", "family": "qwen", "capabilities": { "supports_tools": true, @@ -305,40 +265,11 @@ "pricing": { "input": 2.5, "output": 10.0, - "currency": "CNY", - "price_version": "2026061601" - } - }, - "qwen3.8-max": { - "name": "Qwen3.8-Max", - "family": "qwen3.8", - "capabilities": { - "supports_tools": true, - "supports_vision": true, - "supports_reasoning": true, - "interleaved": { - "field": "reasoning_content", - "echo": "tool_calls", - "cross_provider_policy": "promote" - }, - "thinking_level_map": { - "high": "enabled" - }, - "supports_streaming": true - }, - "limits": { - "context_window": 1000000, - "max_output_tokens": 65536 - }, - "pricing": { - "input": 12.0, - "output": 36.0, - "currency": "CNY", - "price_version": "2026080301" + "currency": "CNY" } }, "kimi-k2.6": { - "name": "Kimi-K2.6", + "name": "kimi-k2.6", "family": "kimi-k2.6", "capabilities": { "supports_tools": true, @@ -363,12 +294,12 @@ "pricing": { "input": 6.5, "output": 27.0, - "currency": "CNY", - "price_version": "2026061601" + "cache_read": 1.3, + "currency": "CNY" } }, "deepseek-v4-flash": { - "name": "DeepSeek-V4-Flash", + "name": "deepseek-v4-flash", "family": "deepseek-v4", "capabilities": { "supports_tools": true, @@ -389,26 +320,7 @@ "pricing": { "input": 1.0, "output": 2.0, - "currency": "CNY", - "price_version": "2026061601" - } - }, - "testadd": { - "name": "testadd", - "family": "testadd", - "capabilities": { - "supports_tools": true, - "supports_streaming": true - }, - "limits": { - "context_window": 128000, - "max_output_tokens": 4096 - }, - "pricing": { - "input": 2.0, - "output": 3.0, - "currency": "CNY", - "price_version": "2026081401" + "currency": "CNY" } } } diff --git a/flocks/provider/cost_calculator.py b/flocks/provider/cost_calculator.py index 34428e4a9..d8d713f3e 100644 --- a/flocks/provider/cost_calculator.py +++ b/flocks/provider/cost_calculator.py @@ -39,25 +39,12 @@ def calculate( """ unit = pricing.unit if pricing.unit > 0 else 1_000_000 - input_price = pricing.input - output_price = pricing.output - if pricing.price_tiers: - # Router selects a single tier from the request's complete prompt - # token count, then applies that tier to both input and output. - selected_tier = pricing.price_tiers[-1] - for tier in pricing.price_tiers: - if tier.max_input_tokens is None or input_tokens <= tier.max_input_tokens: - selected_tier = tier - break - input_price = selected_tier.input_price - output_price = selected_tier.output_price - # Input cost: non-cached tokens at input price billable_input = max(0, input_tokens - cached_tokens) - input_cost = (billable_input / unit) * input_price + input_cost = (billable_input / unit) * pricing.input # Output cost - output_cost = (output_tokens / unit) * output_price + output_cost = (output_tokens / unit) * pricing.output # Cache cost cache_cost = 0.0 diff --git a/flocks/provider/model_catalog.py b/flocks/provider/model_catalog.py index c7f6ba8e0..3233bcbf0 100644 --- a/flocks/provider/model_catalog.py +++ b/flocks/provider/model_catalog.py @@ -159,8 +159,6 @@ def _parse_model_definitions( cache_read=pricing_raw.get("cache_read"), cache_write=pricing_raw.get("cache_write"), currency=pricing_raw.get("currency", "USD"), - price_tiers=pricing_raw.get("price_tiers"), - price_version=pricing_raw.get("price_version"), ) model_type_str = m.get("model_type", "llm") diff --git a/flocks/provider/provider.py b/flocks/provider/provider.py index e66d2322b..36e96a8e8 100644 --- a/flocks/provider/provider.py +++ b/flocks/provider/provider.py @@ -57,12 +57,6 @@ def _model_info_signature(model: "ModelInfo") -> tuple: pricing.get("cache_read") if isinstance(pricing, dict) else None, pricing.get("cache_write") if isinstance(pricing, dict) else None, pricing.get("currency") if isinstance(pricing, dict) else None, - json.dumps( - pricing.get("price_tiers") if isinstance(pricing, dict) else None, - default=str, - sort_keys=True, - ), - pricing.get("price_version") if isinstance(pricing, dict) else None, ) if pricing is not None else None @@ -737,49 +731,6 @@ def list_models(cls, provider_id: Optional[str] = None) -> List[ModelInfo]: all_models.extend(provider.get_models()) return all_models - @classmethod - async def refresh_provider_models( - cls, - provider_ids: Optional[List[str]] = None, - *, - force: bool = False, - ) -> None: - """Refresh provider-owned dynamic model catalogs when supported. - - Most providers keep their model list in ``flocks.json`` and therefore - do not implement ``refresh_models``. Providers backed by an - authoritative remote catalog (currently ThreatBook CN Router) can - expose that coroutine; failures stay isolated so model-list APIs keep - serving their bundled/configured fallback data. - """ - cls._ensure_initialized() - target_ids = ( - list(cls._providers.keys()) - if provider_ids is None - else provider_ids - ) - refreshes = [] - refresh_ids = [] - for pid in target_ids: - provider = cls._providers.get(pid) - refresh = getattr(provider, "refresh_models", None) if provider else None - if callable(refresh): - refresh_ids.append(pid) - refreshes.append(refresh(force=force)) - - if not refreshes: - return - - import asyncio - - results = await asyncio.gather(*refreshes, return_exceptions=True) - for pid, result in zip(refresh_ids, results): - if isinstance(result, Exception): - log.warning("provider.models.refresh_failed", { - "provider_id": pid, - "error": str(result), - }) - @classmethod async def apply_config(cls, config: Optional[Any] = None, provider_id: Optional[str] = None) -> None: """ @@ -1301,8 +1252,6 @@ def _build_model_definition(self, model: "ModelInfo") -> "ModelDefinition": cache_read=model.pricing.get("cache_read"), cache_write=model.pricing.get("cache_write"), currency=model.pricing.get("currency", "USD"), - price_tiers=model.pricing.get("price_tiers"), - price_version=model.pricing.get("price_version"), ) max_output = model.capabilities.max_tokens or 4096 return ModelDefinition( @@ -1408,32 +1357,18 @@ def _apply_config_overrides(self, catalog_def: "ModelDefinition", model: "ModelI cache_read=model.pricing.get("cache_read"), cache_write=model.pricing.get("cache_write"), currency=model.pricing.get("currency", "USD"), - price_tiers=model.pricing.get("price_tiers"), - price_version=model.pricing.get("price_version"), ) return overridden - def _get_model_definition_source_models(self) -> List["ModelInfo"]: - """Return the model list used to build rich definitions. - - Subclasses with an authoritative dynamic catalog can override this - hook without mutating ``_config_models``, which remains the user's - persisted configuration snapshot. - """ - source_models = list(getattr(self, "_config_models", [])) - return source_models if source_models else list(self.get_models()) - def get_model_definitions(self) -> List["ModelDefinition"]: - """Return rich model definitions for the provider's active model list. + """Return model definitions for models in flocks.json (_config_models). If CATALOG_ID is set, catalog.json is used as a metadata source: models whose ID appears in the catalog get the richer catalog entry (parameter_rules, release_date, etc.); all others fall back to the config data in flocks.json. - By default, flocks.json is the source of truth for *which* models are - listed. Dynamic providers may override - ``_get_model_definition_source_models``. User-edited values in - flocks.json still override catalog defaults where applicable. + flocks.json is the single source of truth for *which* models are listed. + User-edited values in flocks.json always override catalog defaults. """ catalog_by_id: dict = {} if self.CATALOG_ID: @@ -1445,7 +1380,9 @@ def get_model_definitions(self) -> List["ModelDefinition"]: except Exception: pass - source_models = self._get_model_definition_source_models() + source_models = list(getattr(self, "_config_models", [])) + if not source_models: + source_models = list(self.get_models()) result = [] for model in source_models: diff --git a/flocks/provider/sdk/threatbook.py b/flocks/provider/sdk/threatbook.py index e8791f76b..8e346f1ea 100644 --- a/flocks/provider/sdk/threatbook.py +++ b/flocks/provider/sdk/threatbook.py @@ -1,479 +1,24 @@ -"""ThreatBook LLM provider implementations. - -The China service is backed by Flocks Router. Router's model catalog is the -authority for enabled models, names, prices, versions, and input-token price -tiers. The bundled catalog remains an offline fallback and supplies -capability/limit metadata that Router does not expose. """ +ThreatBook LLM provider implementations. -import asyncio -import hashlib -import os -import time -from typing import Any, Optional -from urllib.parse import urlsplit - -import httpx +ThreatBook provides OpenAI-compatible endpoints for accessing hosted models. +Models are loaded from catalog.json and user-added custom models from +flocks.json by the parent OpenAIBaseProvider.get_models(). +""" -from flocks.provider.model_catalog import get_provider_model_definitions -from flocks.provider.provider import ModelCapabilities, ModelInfo -from flocks.provider.sdk.openai_base import ( - OpenAIBaseProvider, - _coerce_bool, - resolve_verify_ssl, -) +from flocks.provider.sdk.openai_base import OpenAIBaseProvider class ThreatBookCnLLMProvider(OpenAIBaseProvider): """ThreatBook-China LLM provider (OpenAI-compatible).""" DEFAULT_BASE_URL = "https://llm.threatbook.cn/v1" - DEFAULT_MODEL_CATALOG_URL = ( - "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" - ) ENV_API_KEY = ["THREATBOOK_CN_LLM_API_KEY"] ENV_BASE_URL = "THREATBOOK_CN_LLM_BASE_URL" CATALOG_ID = "threatbook-cn-llm" - MODEL_CATALOG_CACHE_TTL_SECONDS = 60.0 - MODEL_CATALOG_TIMEOUT_SECONDS = 5.0 - MODEL_CATALOG_SESSION_SECRET_PREFIX = ( - "threatbook-cn-llm_model_catalog_session_" - ) def __init__(self): super().__init__(provider_id="threatbook-cn-llm", name="ThreatBook-cn-llm") - self._router_models: Optional[list[ModelInfo]] = None - self._router_models_url: Optional[str] = None - self._router_models_last_attempt = 0.0 - self._router_models_last_attempt_url: Optional[str] = None - self._router_models_lock = asyncio.Lock() - - def get_models(self) -> list[ModelInfo]: - """Return Router models after the first successful discovery. - - ``None`` and ``[]`` intentionally mean different things: ``None`` has - never been refreshed successfully and falls back to flocks.json; - ``[]`` is a valid authoritative response when Router has no active - models. - """ - if self._router_models is not None: - return list(self._router_models) - if not getattr(self, "_config_models", []): - return [] - # Existing installations can contain obsolete price fields in - # flocks.json. Build the offline fallback from the bundled Router - # snapshot so those stale values cannot override current defaults. - fallback_rows = [] - for model in get_provider_model_definitions(self.CATALOG_ID): - row: dict[str, Any] = {"model_name": model.name} - if model.pricing: - row["input_price"] = model.pricing.input - row["output_price"] = model.pricing.output - row["price_tiers"] = ( - [tier.model_dump() for tier in model.pricing.price_tiers] if model.pricing.price_tiers else None - ) - row["price_version"] = model.pricing.price_version - fallback_rows.append(row) - return self._build_router_models(fallback_rows) - - @property - def model_catalog_is_authoritative(self) -> bool: - """Router (or its bundled snapshot) owns this provider's model list.""" - return True - - def _get_model_definition_source_models(self) -> list[ModelInfo]: - return self.get_models() - - def _model_catalog_urls(self) -> list[str]: - """Return only the explicitly configured Router catalog endpoint.""" - custom_settings = getattr(self._config, "custom_settings", None) or {} - configured_url = ( - custom_settings.get("model_catalog_url") if isinstance(custom_settings, dict) else None - ) or os.getenv("THREATBOOK_CN_LLM_MODEL_CATALOG_URL") - - catalog_url = configured_url or self.DEFAULT_MODEL_CATALOG_URL - return [catalog_url] - - @classmethod - def supports_model_catalog_session(cls, catalog_url: str) -> bool: - """Only attach a Console session to trusted HTTPS Router URLs.""" - parsed = urlsplit(catalog_url) - hostname = (parsed.hostname or "").lower() - return ( - parsed.scheme == "https" - and ( - hostname == "threatbook-inc.cn" - or hostname.endswith(".threatbook-inc.cn") - ) - and parsed.path.startswith("/api/console/") - ) - - @classmethod - def model_catalog_session_secret_id(cls, catalog_url: str) -> str: - """Build an environment-bound secret ID from the catalog origin.""" - parsed = urlsplit(catalog_url) - origin = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" - origin_hash = hashlib.sha256(origin.encode("utf-8")).hexdigest()[:16] - return f"{cls.MODEL_CATALOG_SESSION_SECRET_PREFIX}{origin_hash}" - - @classmethod - def get_model_catalog_session_token(cls, catalog_url: str) -> Optional[str]: - """Resolve the session for this Router environment without exposing it.""" - env_token = os.getenv("THREATBOOK_CN_LLM_CONSOLE_SESSION_TOKEN") - if env_token and env_token.strip(): - return env_token.strip() - try: - from flocks.security import get_secret_manager - - token = get_secret_manager().get( - cls.model_catalog_session_secret_id(catalog_url) - ) - except Exception: - return None - return token.strip() if isinstance(token, str) and token.strip() else None - - @staticmethod - def _positive_float_env( - name: str, - default: float, - *, - allow_zero: bool = False, - ) -> float: - try: - value = float(os.getenv(name, default)) - except (TypeError, ValueError): - return default - return value if value > 0 or (allow_zero and value == 0) else default - - @staticmethod - def _price_value(value: Any) -> Optional[float]: - if isinstance(value, bool): - return None - try: - parsed = float(value) - except (TypeError, ValueError): - return None - return parsed if parsed >= 0 else None - - @classmethod - def _router_price_tiers(cls, raw: dict[str, Any]) -> list[dict[str, Any]]: - tiers = raw.get("price_tiers", raw.get("priceTiers")) - if not isinstance(tiers, list): - return [] - - parsed_tiers: list[dict[str, Any]] = [] - for tier in tiers: - if not isinstance(tier, dict): - continue - input_price = cls._price_value(tier.get("input_price", tier.get("inputPrice"))) - output_price = cls._price_value(tier.get("output_price", tier.get("outputPrice"))) - if input_price is None or output_price is None: - continue - - raw_max = tier.get("max_input_tokens", tier.get("maxInputTokens")) - if raw_max is None: - max_input_tokens = None - elif isinstance(raw_max, bool): - continue - else: - try: - max_input_tokens = int(raw_max) - except (TypeError, ValueError): - continue - if max_input_tokens < 0: - continue - parsed_tiers.append( - { - "max_input_tokens": max_input_tokens, - "input_price": input_price, - "output_price": output_price, - } - ) - - return sorted( - parsed_tiers, - key=lambda tier: ( - tier["max_input_tokens"] is None, - tier["max_input_tokens"] or 0, - ), - ) - - @classmethod - def _router_default_prices( - cls, - raw: dict[str, Any], - ) -> tuple[Optional[float], Optional[float]]: - """Resolve the price displayed by Router's fee-details page. - - For tiered models, Router documents ``input_price`` / ``output_price`` - as the first tier. Prefer the explicit first tier defensively so Flocks - still matches the fee page if those fields temporarily drift. - """ - input_price = cls._price_value(raw.get("input_price", raw.get("inputPrice"))) - output_price = cls._price_value(raw.get("output_price", raw.get("outputPrice"))) - tiers = cls._router_price_tiers(raw) - if tiers: - input_price = tiers[0]["input_price"] - output_price = tiers[0]["output_price"] - return input_price, output_price - - def _build_router_models(self, rows: list[Any]) -> list[ModelInfo]: - catalog_by_lower = {model.id.lower(): model for model in get_provider_model_definitions(self.CATALOG_ID)} - configured_by_lower = {model.id.lower(): model for model in getattr(self, "_config_models", [])} - result: list[ModelInfo] = [] - seen: set[str] = set() - - for raw in rows: - if not isinstance(raw, dict): - continue - router_name = raw.get("model_name", raw.get("modelName")) - if not isinstance(router_name, str) or not router_name.strip(): - continue - router_name = router_name.strip() - normalized = router_name.lower() - if normalized in seen: - continue - seen.add(normalized) - - catalog = catalog_by_lower.get(normalized) - configured = configured_by_lower.get(normalized) - model_id = catalog.id if catalog else (configured.id if configured else router_name) - - if catalog: - capabilities = ModelCapabilities( - supports_streaming=catalog.capabilities.supports_streaming, - supports_tools=catalog.capabilities.supports_tools, - supports_vision=catalog.capabilities.supports_vision, - supports_reasoning=catalog.capabilities.supports_reasoning, - interleaved=catalog.capabilities.interleaved, - thinking_level_map=catalog.capabilities.thinking_level_map, - max_tokens=catalog.limits.max_output_tokens, - context_window=catalog.limits.context_window, - ) - display_name = router_name - else: - capabilities = ModelCapabilities() - display_name = router_name - - explicit_keys = set(getattr(configured, "_explicit_keys", set()) if configured else set()) - if configured: - configured_capabilities = configured.capabilities - capability_fields = { - "supports_streaming": "supports_streaming", - "supports_tools": "supports_tools", - "supports_vision": "supports_vision", - "supports_reasoning": "supports_reasoning", - "interleaved": "interleaved", - "thinking_level_map": "thinking_level_map", - "max_output_tokens": "max_tokens", - "max_tokens": "max_tokens", - "context_window": "context_window", - } - for config_key, attribute in capability_fields.items(): - if config_key in explicit_keys: - setattr( - capabilities, - attribute, - getattr(configured_capabilities, attribute), - ) - # Router owns model identity and display casing. Persisted - # names from older flocks.json snapshots must not overwrite - # the current Console model name. - - input_price, output_price = self._router_default_prices(raw) - price_tiers = self._router_price_tiers(raw) - raw_price_version = raw.get( - "price_version", - raw.get("priceVersion"), - ) - price_version = str(raw_price_version) if raw_price_version is not None else None - pricing = None - if input_price is not None and output_price is not None: - pricing = { - "input": input_price, - "output": output_price, - "currency": "CNY", - } - if price_tiers: - pricing["price_tiers"] = price_tiers - if price_version: - pricing["price_version"] = price_version - elif catalog and catalog.pricing: - pricing = { - "input": catalog.pricing.input, - "output": catalog.pricing.output, - "currency": catalog.pricing.currency, - } - if catalog.pricing.price_tiers: - pricing["price_tiers"] = [tier.model_dump() for tier in catalog.pricing.price_tiers] - if catalog.pricing.price_version: - pricing["price_version"] = catalog.pricing.price_version - - model = ModelInfo( - id=model_id, - name=display_name, - provider_id=self.id, - capabilities=capabilities, - pricing=pricing, - custom_settings=(dict(configured.custom_settings) if configured else {}), - ) - model._explicit_keys = explicit_keys - result.append(model) - - return result - - async def _fetch_catalog_rows( - self, - client: httpx.AsyncClient, - models_url: str, - headers: dict[str, str], - cookies: Optional[dict[str, str]], - ) -> list[Any]: - """Fetch Router Console's model catalog, including paginated results.""" - page = 1 - page_size = 100 - rows: list[Any] = [] - - while True: - params = {"page": page, "pageSize": page_size} if "/api/console/" in models_url else None - response = await client.get( - models_url, - headers=headers, - cookies=cookies, - params=params, - ) - response.raise_for_status() - payload = response.json() - if not isinstance(payload, dict): - raise ValueError("Router model response is not an object") - if "code" in payload and payload.get("code") != 0: - raise ValueError(f"Router model response failed with code {payload.get('code')}") - - data = payload.get("data") - if isinstance(data, list): - return data - if not isinstance(data, dict) or not isinstance(data.get("list"), list): - raise ValueError("Router model response has no model list") - - rows.extend(data["list"]) - total = data.get("total", len(rows)) - try: - total = max(0, int(total)) - except (TypeError, ValueError): - total = len(rows) - if len(rows) >= total or not data["list"]: - return rows - page += 1 - - async def refresh_models(self, force: bool = False) -> bool: - """Refresh active models and complete pricing from Flocks Router. - - The refresh is rate-limited and failure-safe. A failed request never - clears the last successful Router snapshot or the bundled fallback. - """ - models_urls = self._model_catalog_urls() - attempt_key = "|".join(models_urls) - now = time.monotonic() - ttl = self._positive_float_env( - "THREATBOOK_CN_LLM_MODEL_CACHE_TTL_SECONDS", - self.MODEL_CATALOG_CACHE_TTL_SECONDS, - allow_zero=True, - ) - if ( - not force - and attempt_key == self._router_models_last_attempt_url - and now - self._router_models_last_attempt < ttl - ): - return self._router_models is not None - - async with self._router_models_lock: - now = time.monotonic() - if ( - not force - and attempt_key == self._router_models_last_attempt_url - and now - self._router_models_last_attempt < ttl - ): - return self._router_models is not None - - if self._router_models_url and self._router_models_url not in models_urls: - # Never carry an authoritative snapshot across environments. - # If the new Router is unavailable, fall back to the bundled - # catalog/config rather than showing models from the old URL. - self._router_models = None - self._router_models_url = None - - self._router_models_last_attempt = now - self._router_models_last_attempt_url = attempt_key - custom_settings = getattr(self._config, "custom_settings", None) or {} - verify_ssl = resolve_verify_ssl(custom_settings, default=True) - trust_env = _coerce_bool(os.getenv("FLOCKS_HTTP_TRUST_ENV"), True) - if isinstance(custom_settings, dict) and "trust_env" in custom_settings: - trust_env = _coerce_bool(custom_settings.get("trust_env"), trust_env) - timeout_seconds = self._positive_float_env( - "THREATBOOK_CN_LLM_MODEL_TIMEOUT_SECONDS", - self.MODEL_CATALOG_TIMEOUT_SECONDS, - ) - headers: dict[str, str] = {"Accept": "application/json"} - - models = None - successful_url = None - errors: list[tuple[str, Exception]] = [] - async with httpx.AsyncClient( - timeout=timeout_seconds, - verify=verify_ssl, - trust_env=trust_env, - ) as client: - for models_url in models_urls: - try: - session_token = self.get_model_catalog_session_token( - models_url - ) - cookies = ( - {"session_token": session_token} - if session_token - and self.supports_model_catalog_session(models_url) - else None - ) - rows = await self._fetch_catalog_rows( - client, - models_url, - headers, - cookies, - ) - candidate_models = self._build_router_models(rows) - if rows and not candidate_models: - raise ValueError("Router model response has no valid model entries") - except Exception as exc: - errors.append((models_url, exc)) - continue - models = candidate_models - successful_url = models_url - break - - if models is None or successful_url is None: - failed_url, exc = errors[-1] - self.log.warning( - "router.models.refresh_failed", - { - "url": failed_url, - "attempted_urls": [url for url, _ in errors], - "error_type": type(exc).__name__, - "error": str(exc), - "using_fallback": self._router_models is None, - }, - ) - return False - - self._router_models = models - self._router_models_url = successful_url - self.log.info( - "router.models.refreshed", - { - "url": successful_url, - "count": len(models), - }, - ) - return True class ThreatBookIoLLMProvider(OpenAIBaseProvider): diff --git a/flocks/provider/types.py b/flocks/provider/types.py index d371e6c0f..51d6915d8 100644 --- a/flocks/provider/types.py +++ b/flocks/provider/types.py @@ -226,17 +226,6 @@ class ModelLimits(BaseModel): max_output_tokens: int = 4096 -class PriceTierConfig(BaseModel): - """Input-token price tier (prices are per ``PriceConfig.unit`` tokens).""" - max_input_tokens: Optional[int] = Field( - None, - ge=0, - description="Inclusive input-token upper bound; null means no upper bound", - ) - input_price: float = Field(0.0, ge=0) - output_price: float = Field(0.0, ge=0) - - class PriceConfig(BaseModel): """价格配置 (每百万 token)""" input: float = 0.0 @@ -245,8 +234,6 @@ class PriceConfig(BaseModel): currency: str = "USD" cache_read: Optional[float] = None cache_write: Optional[float] = None - price_tiers: Optional[List[PriceTierConfig]] = None - price_version: Optional[str] = None class ModelCapabilitiesV2(BaseModel): diff --git a/flocks/provider/usage_service.py b/flocks/provider/usage_service.py index 657b77c16..6679db64a 100644 --- a/flocks/provider/usage_service.py +++ b/flocks/provider/usage_service.py @@ -124,24 +124,16 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf """Resolve runtime pricing for a provider/model pair.""" model_info = None provider = Provider.get(provider_id) - if ( - provider - and getattr(provider, "model_catalog_is_authoritative", False) is True - ): - model_info = Provider.resolve_model(provider_id, model_id) - elif provider: + if provider: for candidate in getattr(provider, "_config_models", []): if candidate.id == model_id: model_info = candidate break + if model_info is None: + model_info = Provider.get_model(model_id) + pricing = getattr(model_info, "pricing", None) if model_info else None - if pricing is None: - # Config snapshots commonly contain only a model name. Resolve the - # catalog-enriched/provider-owned definition so bundled or dynamically - # refreshed Router prices are still used for usage accounting. - model_info = Provider.resolve_model(provider_id, model_id) - pricing = getattr(model_info, "pricing", None) if model_info else None if pricing is None: return None @@ -156,8 +148,6 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf currency=getattr(pricing, "currency", "USD"), cache_read=getattr(pricing, "cache_read", None), cache_write=getattr(pricing, "cache_write", None), - price_tiers=getattr(pricing, "price_tiers", None), - price_version=getattr(pricing, "price_version", None), ) if isinstance(pricing, dict): @@ -168,8 +158,6 @@ def resolve_usage_pricing(provider_id: str, model_id: str) -> Optional[PriceConf currency=pricing.get("currency", "USD"), cache_read=pricing.get("cache_read"), cache_write=pricing.get("cache_write"), - price_tiers=pricing.get("price_tiers"), - price_version=pricing.get("price_version"), ) return None diff --git a/flocks/server/routes/model.py b/flocks/server/routes/model.py index a8f5c9324..e4f0bff80 100644 --- a/flocks/server/routes/model.py +++ b/flocks/server/routes/model.py @@ -32,17 +32,6 @@ def _connected_provider_ids() -> set[str]: return set(ConfigWriter.list_provider_ids()) -async def _refresh_connected_provider(provider_id: str) -> None: - """Apply config and refresh a connected provider's dynamic catalog.""" - if provider_id not in _connected_provider_ids(): - return - from flocks.config.config import Config - - config = await Config.get() - await Provider.apply_config(config, provider_id=provider_id) - await Provider.refresh_provider_models([provider_id]) - - # ==================== Response Models ==================== class ModelCapabilities(BaseModel): @@ -424,7 +413,6 @@ async def list_model_definitions( provider: Optional[str] = Query(None, description="Filter by provider ID"), model_type: Optional[ModelType] = Query(None, description="Filter by model type"), enabled_only: bool = Query(False, description="Only return enabled models"), - refresh: bool = Query(False, description="Force refresh dynamic provider catalogs"), ) -> ModelDefinitionListResponse: """List model definitions with full metadata.""" try: @@ -434,9 +422,6 @@ async def list_model_definitions( try: config = await Config.get() await Provider.apply_config(config, provider_id=provider) - connected = _connected_provider_ids() - refresh_ids = [provider] if provider and provider in connected else list(connected) - await Provider.refresh_provider_models(refresh_ids, force=refresh) except Exception: pass @@ -468,7 +453,6 @@ async def list_model_definitions( ) async def get_parameter_rules(provider_id: str, model_id: str): """Get parameter rules for a model.""" - await _refresh_connected_provider(provider_id) manager = get_model_manager() definition = manager.get_model(provider_id, model_id) if not definition: @@ -489,7 +473,6 @@ async def get_model_definition( provider_id: str, model_id: str ) -> ModelDefinition: """Get a single model definition.""" - await _refresh_connected_provider(provider_id) manager = get_model_manager() definition = manager.get_model(provider_id, model_id) if not definition: diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 3dc4bfd24..4f28570cf 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -8,13 +8,11 @@ import asyncio import json -import os import re import threading import time from pathlib import Path from typing import Any, Dict, List, Optional -from urllib.parse import urlparse from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status from pydantic import BaseModel, Field, ConfigDict @@ -441,13 +439,6 @@ def _merge_config_models( provider_id: str, config: Any, ) -> Dict[str, Dict[str, Any]]: - provider = Provider.get(provider_id) - if getattr(provider, "model_catalog_is_authoritative", False) is True: - # The provider has already merged matching user overrides into its - # live Router snapshot (or bundled offline snapshot). Re-adding every - # persisted model here would resurrect removed models and stale prices. - return models_dict - provider_cfg = (getattr(config, "provider", None) or {}).get(provider_id) if not provider_cfg or not getattr(provider_cfg, "models", None): return models_dict @@ -545,7 +536,6 @@ async def list_providers() -> ProviderListResponse: try: config = await Config.get() await Provider.apply_config(config) - await Provider.refresh_provider_models(ConfigWriter.list_provider_ids()) disabled = set(config.disabled_providers or []) enabled_set = set(config.enabled_providers) if config.enabled_providers else None except Exception: @@ -670,7 +660,6 @@ async def get_provider_catalog(): "credential_schemas": [s.model_dump() for s in meta.credential_schemas], "env_vars": raw.get("env_vars", []), "default_base_url": raw.get("default_base_url"), - "default_model_catalog_url": raw.get("default_model_catalog_url"), "model_count": len(models), "models": [ { @@ -696,10 +685,6 @@ async def get_provider_catalog(): "cache_read": m.pricing.cache_read, "cache_write": m.pricing.cache_write, "currency": m.pricing.currency, - "price_tiers": [ - tier.model_dump() for tier in m.pricing.price_tiers - ] if m.pricing.price_tiers else None, - "price_version": m.pricing.price_version, } if m.pricing else None, } for m in models @@ -841,7 +826,6 @@ async def get_provider(provider_id: str) -> ProviderInfo: config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) - await Provider.refresh_provider_models([provider_id]) # Credentials are resolved at config load time via {secret:xxx} in flocks.json. # Provider.apply_config() above already configures from resolved config. @@ -893,7 +877,6 @@ async def list_models(provider_id: str) -> List[Dict[str, Any]]: return [] config = await Config.get() await Provider.apply_config(config, provider_id=provider_id) - await Provider.refresh_provider_models([provider_id]) provider_models = Provider.list_models(provider_id) models_dict: Dict[str, Dict[str, Any]] = {} @@ -1631,6 +1614,7 @@ async def get_api_service_metadata(provider_id: str): if not base_url and data.get("apis") and len(data["apis"]) > 0: first_endpoint = data["apis"][0].get("endpoint", "") if first_endpoint: + from urllib.parse import urlparse parsed = urlparse(first_endpoint) base_url = f"{parsed.scheme}://{parsed.netloc}" @@ -1683,14 +1667,6 @@ class ProviderCredentialRequest(BaseModel): api_key: Optional[str] = Field(None, description="API key value") secret: Optional[str] = Field(None, description="Secondary secret value for custom API services") base_url: Optional[str] = Field(None, description="Base URL for the provider") - model_catalog_url: Optional[str] = Field( - None, - description="Model catalog URL, independent from the provider chat Base URL", - ) - model_catalog_session_token: Optional[str] = Field( - None, - description="Router Console session token used only for model catalog sync", - ) username: Optional[str] = Field(None, description="Optional username for API services") fields: Optional[Dict[str, Optional[str]]] = Field(None, description="Dynamic service credential fields") provider_name: Optional[str] = Field(None, description="Display name for the provider") @@ -1704,9 +1680,6 @@ class ProviderCredentialResponse(BaseModel): secret: Optional[str] = None secret_masked: Optional[str] = None base_url: Optional[str] = None - model_catalog_url: Optional[str] = None - model_catalog_session_token_masked: Optional[str] = None - has_model_catalog_session: bool = False username: Optional[str] = None fields: Optional[Dict[str, Optional[str]]] = None secret_ids: Optional[Dict[str, str]] = None @@ -1731,40 +1704,12 @@ def _load_llm_provider_credentials( api_key = _get_inline_provider_api_key(provider_id) base_url = None - model_catalog_url = None raw_provider = ConfigWriter.get_provider_raw(provider_id) if raw_provider: options = raw_provider.get("options", {}) base_url = options.get("baseURL") or options.get("base_url") - model_catalog_url = options.get("model_catalog_url") or options.get( - "modelCatalogURL" - ) if not base_url: base_url = secrets.get(f"{provider_id}_base_url") - if not model_catalog_url: - model_catalog_url = os.getenv("THREATBOOK_CN_LLM_MODEL_CATALOG_URL") - if not model_catalog_url: - try: - from flocks.provider.model_catalog import get_raw_catalog - - model_catalog_url = get_raw_catalog().get(provider_id, {}).get( - "default_model_catalog_url" - ) - except Exception: - pass - - model_catalog_session_token = None - if provider_id == "threatbook-cn-llm" and model_catalog_url: - try: - from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider - - model_catalog_session_token = ( - ThreatBookCnLLMProvider.get_model_catalog_session_token( - model_catalog_url - ) - ) - except Exception: - pass is_placeholder = _is_placeholder_api_key(api_key) ui_api_key = None if is_placeholder else api_key @@ -1774,13 +1719,6 @@ def _load_llm_provider_credentials( api_key=ui_api_key if reveal_api_key else None, api_key_masked=SecretManager.mask(ui_api_key) if ui_api_key else None, base_url=base_url, - model_catalog_url=model_catalog_url, - model_catalog_session_token_masked=( - SecretManager.mask(model_catalog_session_token) - if model_catalog_session_token - else None - ), - has_model_catalog_session=bool(model_catalog_session_token), has_credential=bool(api_key), ) @@ -1867,8 +1805,6 @@ async def set_provider_credentials( - api_key → .secret.json - base_url → flocks.json provider.{id}.options.baseURL - - model_catalog_url → flocks.json provider.{id}.options.model_catalog_url - - model_catalog_session_token → environment-bound local secret storage - Ensures provider entry exists in flocks.json - Configures provider runtime immediately """ @@ -1916,57 +1852,6 @@ async def set_provider_credentials( # (which require a non-empty key argument) keep working. effective_api_key = _NO_API_KEY_PLACEHOLDER - model_catalog_url_was_set = "model_catalog_url" in request.model_fields_set - effective_model_catalog_url = None - if model_catalog_url_was_set: - effective_model_catalog_url = (request.model_catalog_url or "").strip() - if effective_model_catalog_url: - parsed_catalog_url = urlparse(effective_model_catalog_url) - if ( - parsed_catalog_url.scheme not in {"http", "https"} - or not parsed_catalog_url.netloc - ): - raise HTTPException( - status_code=400, - detail="Model catalog URL must be an absolute HTTP(S) URL", - ) - - model_catalog_session_was_set = ( - "model_catalog_session_token" in request.model_fields_set - ) - effective_model_catalog_session = ( - (request.model_catalog_session_token or "").strip() - if model_catalog_session_was_set - else None - ) - catalog_session_secret_id = None - if model_catalog_session_was_set: - if provider_id != "threatbook-cn-llm": - raise HTTPException( - status_code=400, - detail="Model catalog session is only supported for ThreatBook-cn-llm", - ) - session_catalog_url = effective_model_catalog_url - if not model_catalog_url_was_set: - current = _load_llm_provider_credentials(provider_id) - session_catalog_url = current.model_catalog_url - from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider - - if not session_catalog_url or not ThreatBookCnLLMProvider.supports_model_catalog_session( - session_catalog_url - ): - raise HTTPException( - status_code=400, - detail=( - "Model catalog session requires an HTTPS ThreatBook Console catalog URL" - ), - ) - catalog_session_secret_id = ( - ThreatBookCnLLMProvider.model_catalog_session_secret_id( - session_catalog_url - ) - ) - # 1. Save API key to .secret.json using _llm_key convention for LLM providers if preserve_existing_secret: log.info("provider.credentials.preserved", { @@ -1990,15 +1875,6 @@ async def set_provider_credentials( "base_url": request.base_url, }) - if model_catalog_session_was_set and catalog_session_secret_id: - if effective_model_catalog_session: - secrets.set( - catalog_session_secret_id, - effective_model_catalog_session, - ) - else: - secrets.delete(catalog_session_secret_id) - # 2. Ensure provider entry exists in flocks.json and update base_url / name raw_provider = ConfigWriter.get_provider_raw(provider_id) if raw_provider: @@ -2018,12 +1894,6 @@ async def set_provider_credentials( ConfigWriter.update_provider_field( provider_id, "name", request.provider_name ) - if model_catalog_url_was_set: - ConfigWriter.update_provider_field( - provider_id, - "options.model_catalog_url", - effective_model_catalog_url or None, - ) else: # Provider not yet in flocks.json — create a minimal entry # Use model_catalog for model defaults; fall back to SDK built-ins. @@ -2066,11 +1936,6 @@ async def set_provider_credentials( npm=npm, base_url=effective_base_url, models=models, - extra_options=( - {"model_catalog_url": effective_model_catalog_url} - if effective_model_catalog_url - else None - ), ) if request.provider_name: config_dict["name"] = request.provider_name @@ -2093,11 +1958,6 @@ async def set_provider_credentials( effective_base_url = raw.get("options", {}).get("baseURL") custom_settings = _get_provider_custom_settings(provider) - if model_catalog_url_was_set: - if effective_model_catalog_url: - custom_settings["model_catalog_url"] = effective_model_catalog_url - else: - custom_settings.pop("model_catalog_url", None) provider.configure(ProviderConfig( provider_id=provider_id, api_key=effective_api_key, @@ -2157,14 +2017,6 @@ async def delete_provider_credentials( deleted_secret = secrets.delete(f"{provider_id}_api_key") or deleted_secret # Also clean up legacy base_url entries secrets.delete(f"{provider_id}_base_url") - if provider_id == "threatbook-cn-llm": - from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider - - for stored_secret_id in secrets.list(): - if stored_secret_id.startswith( - ThreatBookCnLLMProvider.MODEL_CATALOG_SESSION_SECRET_PREFIX - ): - deleted_secret = secrets.delete(stored_secret_id) or deleted_secret if not removed_config and not deleted_secret: raise HTTPException(status_code=404, detail="No credentials found for this provider") diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index c7a112fd3..3b3945f2f 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -338,11 +338,9 @@ def test_threatbook_cn_llm_catalog(self): "GLM-5", "qwen3.6-plus", "qwen3-max", - "qwen3.8-max", "kimi-k2.6", "deepseek-v4-flash", "deepseek-v4-flash-0731", - "testadd", } assert models[0].id == "deepseek-v4-flash-0731" @@ -351,36 +349,20 @@ def test_threatbook_cn_llm_catalog(self): assert kimi_code.capabilities.supports_reasoning is True assert kimi_code.capabilities.interleaved["field"] == "reasoning_content" assert kimi_code.pricing.currency == "CNY" - assert kimi_code.pricing.cache_read is None + assert kimi_code.pricing.cache_read == 1.3 assert kimi_code.pricing.input == 6.5 assert kimi_code.pricing.output == 27.0 - assert kimi_code.pricing.price_version == "2026072001" assert kimi_code.limits.context_window == 256000 assert kimi_code.limits.max_input_tokens == 224000 assert kimi_code.limits.max_output_tokens == 16000 qwen = next(m for m in models if m.id == "qwen3.6-plus") assert qwen.capabilities.supports_vision is True - qwen38 = next(m for m in models if m.id == "qwen3.8-max") - assert qwen38.capabilities.supports_vision is True - assert qwen38.limits.context_window == 1000000 - assert qwen38.limits.max_output_tokens == 65536 - assert qwen38.pricing.input == 12.0 - assert qwen38.pricing.output == 36.0 m3 = next(m for m in models if m.id == "minimax-m3") assert m3.capabilities.supports_vision is True assert m3.capabilities.supports_reasoning is True assert m3.capabilities.interleaved["field"] == "reasoning_details" - assert m3.name == "MiniMax-M3" - assert m3.pricing.price_version == "2026061601" - assert len(m3.pricing.price_tiers) == 2 - assert m3.pricing.price_tiers[0].max_input_tokens == 512000 - assert m3.pricing.price_tiers[1].input_price == 8.4 - assert m3.pricing.price_tiers[1].output_price == 33.6 - m25 = next(m for m in models if m.id == "minimax-m2.5") - assert m25.pricing.input == 2.1 - assert m25.pricing.output == 8.42 flash_cn = next(m for m in models if m.id == "deepseek-v4-flash") assert flash_cn.pricing.input == 1.0 @@ -389,23 +371,25 @@ def test_threatbook_cn_llm_catalog(self): assert flash_cn.limits.context_window == 1000000 assert flash_cn.limits.max_output_tokens == 384000 raw_models = get_raw_catalog()["threatbook-cn-llm"]["models"] - flash_0731 = next(m for m in models if m.id == "deepseek-v4-flash-0731") - assert flash_0731.name == "DeepSeek-V4-Flash-0731" - assert flash_0731.pricing.price_version == "2026081405" - assert [tier.max_input_tokens for tier in flash_0731.pricing.price_tiers] == [ - 100000, - 10000000, - 100000000, - None, - ] - assert raw_models["deepseek-v4-flash-0731"]["limits"]["max_input_tokens"] == 1000000 + assert raw_models["deepseek-v4-flash-0731"] == { + **raw_models["deepseek-v4-flash"], + "name": "deepseek-v4-flash-0731", + "limits": { + **raw_models["deepseek-v4-flash"]["limits"], + "max_input_tokens": 1000000, + }, + "pricing": { + **raw_models["deepseek-v4-flash"]["pricing"], + "cache_read": 0.2, + }, + } kimi = next(m for m in models if m.id == "kimi-k2.6") assert kimi.capabilities.supports_vision is True assert kimi.capabilities.supports_reasoning is True assert kimi.capabilities.interleaved["field"] == "reasoning_content" assert kimi.pricing.currency == "CNY" - assert kimi.pricing.cache_read is None + assert kimi.pricing.cache_read == 1.3 assert kimi.pricing.input == 6.5 assert kimi.pricing.output == 27.0 assert kimi.limits.context_window == 256000 diff --git a/tests/provider/test_model_management_p2p3.py b/tests/provider/test_model_management_p2p3.py index c0d8912aa..e4ff4ea61 100644 --- a/tests/provider/test_model_management_p2p3.py +++ b/tests/provider/test_model_management_p2p3.py @@ -23,7 +23,6 @@ ModelSetting, ModelType, PriceConfig, - PriceTierConfig, UsageCost, ) @@ -433,36 +432,6 @@ def test_no_cache_pricing(self): # cache_read is None, so no cache cost assert cost.cache_cost == 0.0 - def test_router_tiered_pricing_uses_prompt_token_bucket(self): - from flocks.provider.cost_calculator import CostCalculator - - pricing = PriceConfig( - input=4.2, - output=16.8, - currency="CNY", - price_tiers=[ - PriceTierConfig( - max_input_tokens=512000, - input_price=4.2, - output_price=16.8, - ), - PriceTierConfig( - max_input_tokens=None, - input_price=8.4, - output_price=33.6, - ), - ], - ) - - first_tier = CostCalculator.calculate(512000, 100000, pricing) - assert first_tier.input_cost == 2.1504 - assert first_tier.output_cost == 1.68 - - second_tier = CostCalculator.calculate(600000, 100000, pricing) - assert second_tier.input_cost == 5.04 - assert second_tier.output_cost == 3.36 - assert second_tier.total_cost == 8.4 - # ==================== usage.py (recording & stats — SQLite dynamic data) ==================== diff --git a/tests/server/routes/test_provider_model_bootstrap.py b/tests/server/routes/test_provider_model_bootstrap.py index e1e932790..1dbef2c83 100644 --- a/tests/server/routes/test_provider_model_bootstrap.py +++ b/tests/server/routes/test_provider_model_bootstrap.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock -import httpx import pytest from flocks.config.config_writer import ConfigWriter @@ -8,244 +7,15 @@ get_provider_model_definitions, sync_catalog_models_to_config, ) -from flocks.provider.provider import ModelInfo, Provider, ProviderConfig -from flocks.provider.sdk import threatbook -from flocks.provider.sdk.threatbook import ThreatBookCnLLMProvider from flocks.server.routes import provider as provider_routes class TestThreatBookProviderModelBootstrap: - @pytest.mark.asyncio - async def test_provider_refresh_forwards_force_to_dynamic_catalog( - self, monkeypatch: pytest.MonkeyPatch - ): - calls = [] - - class DynamicProvider: - async def refresh_models(self, force: bool = False): - calls.append(force) - - monkeypatch.setattr(Provider, "_initialized", True) - monkeypatch.setattr(Provider, "_providers", {"dynamic": DynamicProvider()}) - - await Provider.refresh_provider_models(["dynamic"], force=True) - - assert calls == [True] - - @pytest.mark.asyncio - async def test_router_refresh_discovers_models_and_uses_first_tier_price( - self, monkeypatch: pytest.MonkeyPatch - ): - payload = { - "code": 0, - "msg": "ok", - "data": { - "total": 3, - "page": 1, - "pageSize": 100, - "list": [ - { - "modelName": "MiniMax-M3", - "inputPrice": 99, - "outputPrice": 99, - "priceVersion": "2026061601", - "priceTiers": [ - { - "maxInputTokens": 512000, - "inputPrice": 4.2, - "outputPrice": 16.8, - }, - { - "maxInputTokens": None, - "inputPrice": 8.4, - "outputPrice": 33.6, - }, - ], - }, - { - "modelName": "Qwen3.8-Max", - "inputPrice": 12, - "outputPrice": 36, - }, - { - "modelName": "Router-New", - "inputPrice": 3, - "outputPrice": 7, - }, - ], - }, - } - requests = [] - - class FakeClient: - def __init__(self, **_kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return False - - async def get(self, url, headers=None, cookies=None, params=None): - requests.append((url, headers, cookies, params)) - return httpx.Response( - 200, - request=httpx.Request("GET", url), - json=payload, - ) - - monkeypatch.setattr(threatbook.httpx, "AsyncClient", FakeClient) - monkeypatch.setattr( - ThreatBookCnLLMProvider, - "get_model_catalog_session_token", - classmethod(lambda cls, _url: "test-console-session"), - ) - provider = ThreatBookCnLLMProvider() - provider.configure(ProviderConfig( - provider_id=provider.id, - api_key="test-key", - base_url="https://router.example/v1", - custom_settings={ - "trust_env": False, - "model_catalog_url": "https://flocks-router-test.threatbook-inc.cn/api/console/common/models", - }, - )) - - assert await provider.refresh_models(force=True) is True - models = {model.id: model for model in provider.get_models()} - assert set(models) == {"minimax-m3", "qwen3.8-max", "Router-New"} - assert models["minimax-m3"].pricing == { - "input": 4.2, - "output": 16.8, - "currency": "CNY", - "price_tiers": [ - { - "max_input_tokens": 512000, - "input_price": 4.2, - "output_price": 16.8, - }, - { - "max_input_tokens": None, - "input_price": 8.4, - "output_price": 33.6, - }, - ], - "price_version": "2026061601", - } - assert models["minimax-m3"].name == "MiniMax-M3" - assert models["qwen3.8-max"].pricing == { - "input": 12.0, - "output": 36.0, - "currency": "CNY", - } - definitions = {model.id: model for model in provider.get_model_definitions()} - assert definitions["qwen3.8-max"].capabilities.supports_tools is True - assert definitions["qwen3.8-max"].capabilities.supports_vision is True - assert definitions["qwen3.8-max"].limits.context_window == 1000000 - assert definitions["Router-New"].pricing.input == 3.0 - assert requests[0][0] == ( - "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" - ) - assert requests[0][1] == {"Accept": "application/json"} - assert requests[0][2] == {"session_token": "test-console-session"} - assert requests[0][3] == {"page": 1, "pageSize": 100} - - def test_model_catalog_uses_only_explicitly_configured_url(self): - provider = ThreatBookCnLLMProvider() - provider.configure(ProviderConfig( - provider_id=provider.id, - api_key="test-key", - base_url="https://chat-test.example/v1", - custom_settings={ - "model_catalog_url": "https://router-prod.example/api/console/common/models" - }, - )) - - assert provider._model_catalog_urls() == [ - "https://router-prod.example/api/console/common/models", - ] - - def test_model_catalog_session_is_bound_to_router_environment(self): - test_url = ( - "https://flocks-router-test.threatbook-inc.cn" - "/api/console/common/models" - ) - prod_url = ( - "https://flocks-router.threatbook-inc.cn" - "/api/console/common/models" - ) - - assert ThreatBookCnLLMProvider.supports_model_catalog_session(test_url) - assert ThreatBookCnLLMProvider.supports_model_catalog_session(prod_url) - assert ( - ThreatBookCnLLMProvider.model_catalog_session_secret_id(test_url) - != ThreatBookCnLLMProvider.model_catalog_session_secret_id(prod_url) - ) - assert not ThreatBookCnLLMProvider.supports_model_catalog_session( - "https://example.com/api/console/common/models" - ) - - @pytest.mark.asyncio - async def test_router_refresh_failure_keeps_config_fallback( - self, monkeypatch: pytest.MonkeyPatch - ): - class FailingClient: - def __init__(self, **_kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return False - - async def get(self, url, headers=None, cookies=None, params=None): - return httpx.Response( - 404, - request=httpx.Request("GET", url), - json={"error": {"message": "not found"}}, - ) - - monkeypatch.setattr(threatbook.httpx, "AsyncClient", FailingClient) - provider = ThreatBookCnLLMProvider() - provider._config_models = [ - ModelInfo( - id="fallback-model", - name="fallback-model", - provider_id=provider.id, - ) - ] - provider.configure(ProviderConfig( - provider_id=provider.id, - api_key="test-key", - base_url="https://router.example/v1", - custom_settings={"trust_env": False}, - )) - - assert await provider.refresh_models(force=True) is False - models = {model.id: model for model in provider.get_models()} - assert "fallback-model" not in models - assert models["minimax-m3"].pricing["input"] == 4.2 - assert models["minimax-m3"].pricing["output"] == 16.8 - assert len(models["minimax-m3"].pricing["price_tiers"]) == 2 - assert models["minimax-m2.5"].pricing["output"] == 8.42 - assert models["qwen3.8-max"].pricing == { - "input": 12.0, - "output": 36.0, - "currency": "CNY", - "price_version": "2026080301", - } - @pytest.mark.asyncio async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): result = await provider_routes.get_provider_catalog() providers = {provider["id"]: provider for provider in result["providers"]} - assert providers["threatbook-cn-llm"]["default_model_catalog_url"] == ( - "https://flocks-router-test.threatbook-inc.cn/api/console/common/models" - ) - for provider_id in ("threatbook-cn-llm", "threatbook-io-llm"): models = { model["id"]: model @@ -260,40 +30,9 @@ async def test_catalog_exposes_deepseek_v4_flash_0731_metadata(self): assert model["pricing"] == { "input": 1.0, "output": 2.0, - "cache_read": ( - None if provider_id == "threatbook-cn-llm" else 0.2 - ), + "cache_read": 0.2, "cache_write": None, "currency": "CNY", - "price_tiers": ( - [ - { - "max_input_tokens": 100000, - "input_price": 1.0, - "output_price": 2.0, - }, - { - "max_input_tokens": 10000000, - "input_price": 2.0, - "output_price": 4.0, - }, - { - "max_input_tokens": 100000000, - "input_price": 1.0, - "output_price": 2.0, - }, - { - "max_input_tokens": None, - "input_price": 3.0, - "output_price": 6.0, - }, - ] if provider_id == "threatbook-cn-llm" else None - ), - "price_version": ( - "2026081405" - if provider_id == "threatbook-cn-llm" - else None - ), } @pytest.mark.asyncio @@ -309,10 +48,7 @@ async def test_set_provider_credentials_bootstraps_kimi_k26_from_catalog( result = await provider_routes.set_provider_credentials( "threatbook-cn-llm", - provider_routes.ProviderCredentialRequest( - api_key="tb-key", - model_catalog_url="https://router-prod.example/api/console/common/models", - ), + provider_routes.ProviderCredentialRequest(api_key="tb-key"), ) assert result["success"] is True @@ -320,148 +56,11 @@ async def test_set_provider_credentials_bootstraps_kimi_k26_from_catalog( raw = ConfigWriter.get_provider_raw("threatbook-cn-llm") assert raw is not None assert "kimi-k2.7-code" in raw["models"] - assert raw["models"]["kimi-k2.7-code"]["name"] == "Kimi-K2.7-Code" + assert raw["models"]["kimi-k2.7-code"]["name"] == "kimi-k2.7-code" assert "kimi-k2.6" in raw["models"] - assert raw["models"]["kimi-k2.6"]["name"] == "Kimi-K2.6" - assert raw["options"]["model_catalog_url"] == ( - "https://router-prod.example/api/console/common/models" - ) + assert raw["models"]["kimi-k2.6"]["name"] == "kimi-k2.6" fake_secrets.set.assert_called_once_with("threatbook-cn-llm_llm_key", "tb-key") runtime_provider.configure.assert_called_once() - runtime_config = runtime_provider.configure.call_args.args[0] - assert runtime_config.custom_settings["model_catalog_url"] == ( - "https://router-prod.example/api/console/common/models" - ) - - @pytest.mark.asyncio - async def test_model_catalog_url_rejects_non_http_url_before_writing_secret( - self, monkeypatch: pytest.MonkeyPatch - ): - fake_secrets = MagicMock() - monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) - - with pytest.raises(provider_routes.HTTPException) as exc_info: - await provider_routes.set_provider_credentials( - "threatbook-cn-llm", - provider_routes.ProviderCredentialRequest( - api_key="tb-key", - model_catalog_url="file:///tmp/models.json", - ), - ) - - assert exc_info.value.status_code == 400 - fake_secrets.set.assert_not_called() - - @pytest.mark.asyncio - async def test_model_catalog_session_is_stored_separately_and_masked( - self, monkeypatch: pytest.MonkeyPatch - ): - catalog_url = ( - "https://flocks-router-test.threatbook-inc.cn" - "/api/console/common/models" - ) - ConfigWriter.add_provider( - "threatbook-cn-llm", - ConfigWriter.build_provider_config( - "threatbook-cn-llm", - base_url="https://flocks-router-test.threatbook-inc.cn/v1", - models={}, - extra_options={"model_catalog_url": catalog_url}, - ), - ) - stored = {"threatbook-cn-llm_llm_key": "existing-api-key"} - fake_secrets = MagicMock() - fake_secrets.get.side_effect = stored.get - fake_secrets.set.side_effect = lambda secret_id, value: stored.__setitem__( - secret_id, value - ) - runtime_provider = MagicMock() - runtime_provider._config = ProviderConfig( - provider_id="threatbook-cn-llm", - api_key="existing-api-key", - base_url="https://flocks-router-test.threatbook-inc.cn/v1", - custom_settings={"model_catalog_url": catalog_url}, - ) - monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) - monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) - monkeypatch.setattr( - provider_routes.Provider, - "get", - lambda _provider_id: runtime_provider, - ) - - result = await provider_routes.set_provider_credentials( - "threatbook-cn-llm", - provider_routes.ProviderCredentialRequest( - model_catalog_session_token="test-session-token" - ), - ) - - session_secret_id = ( - ThreatBookCnLLMProvider.model_catalog_session_secret_id(catalog_url) - ) - assert result["success"] is True - assert stored[session_secret_id] == "test-session-token" - assert "model_catalog_session_token" not in str( - ConfigWriter.get_provider_raw("threatbook-cn-llm") - ) - response = provider_routes._load_llm_provider_credentials( - "threatbook-cn-llm" - ) - assert response.has_model_catalog_session is True - assert response.model_catalog_session_token_masked == "te***oken" - - @pytest.mark.asyncio - async def test_existing_provider_model_catalog_url_is_updated_and_returned( - self, monkeypatch: pytest.MonkeyPatch - ): - ConfigWriter.add_provider( - "threatbook-cn-llm", - ConfigWriter.build_provider_config( - "threatbook-cn-llm", - base_url="https://llm.threatbook.cn/v1", - models={}, - extra_options={ - "model_catalog_url": "https://router-test.example/api/console/common/models" - }, - ), - ) - fake_secrets = MagicMock() - fake_secrets.get.return_value = "existing-key" - runtime_provider = MagicMock() - runtime_provider._config = ProviderConfig( - provider_id="threatbook-cn-llm", - api_key="existing-key", - base_url="https://llm.threatbook.cn/v1", - custom_settings={ - "model_catalog_url": "https://router-test.example/api/console/common/models" - }, - ) - monkeypatch.setattr("flocks.security.get_secret_manager", lambda: fake_secrets) - monkeypatch.setattr(provider_routes.Provider, "_ensure_initialized", MagicMock()) - monkeypatch.setattr(provider_routes.Provider, "get", lambda _provider_id: runtime_provider) - - await provider_routes.set_provider_credentials( - "threatbook-cn-llm", - provider_routes.ProviderCredentialRequest( - model_catalog_url="https://router-prod.example/api/console/common/models" - ), - ) - - raw = ConfigWriter.get_provider_raw("threatbook-cn-llm") - assert raw["options"]["model_catalog_url"] == ( - "https://router-prod.example/api/console/common/models" - ) - runtime_config = runtime_provider.configure.call_args.args[0] - assert runtime_config.custom_settings["model_catalog_url"] == ( - "https://router-prod.example/api/console/common/models" - ) - response = provider_routes._load_llm_provider_credentials( - "threatbook-cn-llm" - ) - assert response.model_catalog_url == ( - "https://router-prod.example/api/console/common/models" - ) def test_sync_catalog_models_to_config_backfills_missing_kimi_k26(self): existing_models = { @@ -487,4 +86,4 @@ def test_sync_catalog_models_to_config_backfills_missing_kimi_k26(self): assert added == 1 assert raw is not None assert "kimi-k2.6" in raw["models"] - assert raw["models"]["kimi-k2.6"]["name"] == "Kimi-K2.6" + assert raw["models"]["kimi-k2.6"]["name"] == "kimi-k2.6" diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index 69d23c5d1..f127e70cc 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -198,14 +198,13 @@ export const catalogAPI = { export const modelV2API = { /** List model definitions with full metadata */ - listDefinitions: (options?: { provider?: string; enabled_only?: boolean; refresh?: boolean }) => + listDefinitions: (options?: { provider?: string; enabled_only?: boolean }) => client.get<{ models: ModelDefinitionV2[]; total: number }>( '/api/model/v2/definitions', { params: { ...(options?.provider ? { provider: options.provider } : {}), ...(options?.enabled_only ? { enabled_only: true } : {}), - ...(options?.refresh ? { refresh: true } : {}), }, } ), diff --git a/webui/src/locales/en-US/model.json b/webui/src/locales/en-US/model.json index 17d6c561f..937fdc6ef 100644 --- a/webui/src/locales/en-US/model.json +++ b/webui/src/locales/en-US/model.json @@ -73,8 +73,7 @@ "deprecated": "Deprecated", "testingModel": "Testing", "available": "Available", - "free": "Free", - "tieredPricing": "{{count}} tiers" + "free": "Free" }, "emptyDetail": { "addFirst": "Start adding a provider", @@ -106,12 +105,6 @@ "baseUrl": "Base URL", "baseUrlOptional": "(optional, leave empty for default)", "baseUrlRequired": "Please enter Base URL", - "modelCatalogUrl": "Model Catalog URL", - "modelCatalogUrlHint": "Syncs Router model names, prices, and pricing tiers independently from the Chat Base URL; replace it when promoting environments.", - "modelCatalogSession": "Model Catalog Session", - "modelCatalogSessionPlaceholder": "Paste the session_token for this Router environment", - "modelCatalogSessionKeepExisting": "Leave blank to keep this environment's session", - "modelCatalogSessionHint": "Used only for this catalog URL and stored separately per test/production site; enter the matching session when switching environments.", "apiKey": "API Key", "ollamaNoKey": "(Ollama usually doesn't need this)", "apiKeyOptional": "(optional, leave empty for no-auth gateways)", @@ -146,11 +139,6 @@ "output": "Output", "cacheRead": "Cache read", "currency": "Currency", - "routerPricingManaged": "Pricing is synced from the Router model catalog and cannot be changed in Flocks.", - "priceVersion": "Price version", - "inputTokenRange": "Input token range", - "inputUnitPrice": "Input unit price", - "outputUnitPrice": "Output unit price", "fillModelId": "Please fill in Model ID and Name", "testingConnection": "Testing connection…", "done": "Done", diff --git a/webui/src/locales/zh-CN/model.json b/webui/src/locales/zh-CN/model.json index 75d4469d9..bf9dd91d3 100644 --- a/webui/src/locales/zh-CN/model.json +++ b/webui/src/locales/zh-CN/model.json @@ -73,8 +73,7 @@ "deprecated": "已弃用", "testingModel": "测试中", "available": "可用", - "free": "免费", - "tieredPricing": "阶梯 {{count}} 档" + "free": "免费" }, "emptyDetail": { "addFirst": "开始添加模型供应商", @@ -106,12 +105,6 @@ "baseUrl": "Base URL", "baseUrlOptional": "(可选,留空用默认)", "baseUrlRequired": "请填写 Base URL", - "modelCatalogUrl": "模型目录 URL", - "modelCatalogUrlHint": "用于同步 Router 模型名称、价格和阶梯计费;与 Chat Base URL 相互独立,上线时可直接替换。", - "modelCatalogSession": "模型目录会话", - "modelCatalogSessionPlaceholder": "粘贴当前 Router 环境的 session_token", - "modelCatalogSessionKeepExisting": "留空以保留当前环境的目录会话", - "modelCatalogSessionHint": "仅用于访问该目录 URL,并按测试/生产站点隔离保存;切换环境时需填写对应环境的会话。", "apiKey": "API Key", "ollamaNoKey": "(Ollama 通常不需要)", "apiKeyOptional": "(可选,无鉴权网关可留空)", @@ -146,11 +139,6 @@ "output": "输出", "cacheRead": "缓存命中", "currency": "货币", - "routerPricingManaged": "价格由 Router 模型目录同步,不能在 Flocks 中单独修改。", - "priceVersion": "价格版本", - "inputTokenRange": "输入 Token 区间", - "inputUnitPrice": "输入单价", - "outputUnitPrice": "输出单价", "fillModelId": "请填写模型 ID 和名称", "testingConnection": "正在测试连接…", "done": "完成", diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 5e01baf1c..cabbe5667 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -52,8 +52,6 @@ vi.mock('react-i18next', () => ({ 'form.selectProvider': 'Select Provider...', 'form.baseUrlOptional': '(optional, leave empty for default)', 'form.baseUrlRequired': 'Please enter Base URL', - 'form.modelCatalogUrl': 'Model Catalog URL', - 'form.modelCatalogUrlHint': 'Independent Router catalog endpoint', 'form.apiKeyOptional': '(optional, leave empty for no-auth gateways)', 'form.apiKeyOptionalHint': 'Leave empty for no-auth gateway', 'form.apiKeyKeepExisting': 'Leave blank to keep the existing API key', @@ -237,55 +235,6 @@ describe('ModelPage add provider dialog', () => { }); }); }); - - it('submits a configurable ThreatBook model catalog URL separately from Base URL', async () => { - const user = userEvent.setup(); - mocks.catalogList.mockResolvedValue({ - data: { - providers: [ - { - id: 'threatbook-cn-llm', - name: 'ThreatBook-cn-llm', - description: 'ThreatBook Router', - credential_schemas: [{ - auth_method: 'api_key', - fields: [{ name: 'api_key', label: 'API Key', type: 'secret', required: true, placeholder: 'tb-key' }], - }], - env_vars: [], - default_base_url: 'https://llm.threatbook.cn/v1', - default_model_catalog_url: 'https://flocks-router-test.threatbook-inc.cn/api/console/common/models', - model_count: 0, - models: [], - }, - ], - }, - }); - mocks.setCredentials.mockResolvedValue({ data: { success: true } }); - - renderWithRouter(); - await user.click(screen.getByRole('button', { name: 'Add Provider' })); - await user.click(await screen.findByRole('button', { name: 'Select Provider...' })); - await user.click(await screen.findByRole('button', { name: /ThreatBook-cn-llm/i })); - - const catalogUrlInput = screen.getByDisplayValue( - 'https://flocks-router-test.threatbook-inc.cn/api/console/common/models', - ); - await user.clear(catalogUrlInput); - await user.type(catalogUrlInput, 'https://router-prod.example/api/console/common/models'); - await user.type(screen.getByPlaceholderText('tb-key'), 'tb-secret'); - await user.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => { - expect(mocks.setCredentials).toHaveBeenCalledWith( - 'threatbook-cn-llm', - expect.objectContaining({ - api_key: 'tb-secret', - base_url: 'https://llm.threatbook.cn/v1', - model_catalog_url: 'https://router-prod.example/api/console/common/models', - }), - ); - }); - }); }); describe('ModelPage configure provider dialog', () => { @@ -506,18 +455,7 @@ describe('ModelPage default model selector', () => { modalities: { input: ['text', 'image'], output: ['text'] }, }, limits: { context_window: 200000, max_output_tokens: 8192 }, - pricing: { - input: 1.25, - output: 5, - cache_read: 0.25, - unit: 1000000, - currency: 'USD', - price_version: '2026082601', - price_tiers: [ - { max_input_tokens: 512000, input_price: 1.25, output_price: 5 }, - { max_input_tokens: null, input_price: 2.5, output_price: 10 }, - ], - }, + pricing: { input: 1.25, output: 5, cache_read: 0.25, unit: 1000000, currency: 'USD' }, }, ]; @@ -575,90 +513,9 @@ describe('ModelPage default model selector', () => { expect(tooltip).toHaveTextContent(/1\.25/); expect(tooltip).toHaveTextContent(/\b5(?:\.0+)?\b/); expect(tooltip).toHaveTextContent('$1.25/$5/$0.25/M'); - expect(tooltip).toHaveTextContent('≤ 512,000: $1.25/$5/M'); - expect(tooltip).toHaveTextContent('> 512,000: $2.5/$10/M'); expect(tooltip).toHaveTextContent(/USD|\$/); }); - it('force-refreshes Router models and shows every authoritative pricing tier', async () => { - const user = userEvent.setup(); - const routerProvider = { - id: 'threatbook-cn-llm', - name: 'ThreatBook-cn-llm', - source: 'config', - env: [], - key: null, - options: {}, - models: {}, - configured: true, - modelCount: 1, - category: 'connected', - }; - const routerModel = { - id: 'deepseek-v4-flash-0731', - name: 'DeepSeek-V4-Flash-0731', - provider_id: 'threatbook-cn-llm', - model_type: 'llm', - status: 'active', - fetch_from: 'predefined', - capabilities: { features: [], supports_streaming: true, supports_tools: true }, - limits: { context_window: 1000000, max_output_tokens: 384000 }, - pricing: { - input: 1, - output: 2, - unit: 1000000, - currency: 'CNY', - price_version: '2026081405', - price_tiers: [ - { max_input_tokens: 100000, input_price: 1, output_price: 2 }, - { max_input_tokens: 10000000, input_price: 2, output_price: 4 }, - { max_input_tokens: 100000000, input_price: 1, output_price: 2 }, - { max_input_tokens: null, input_price: 3, output_price: 6 }, - ], - }, - }; - mocks.useProviders.mockReturnValue({ - providers: [routerProvider], - connectedIds: ['threatbook-cn-llm'], - loading: false, - error: null, - refetch: mocks.refetch, - }); - mocks.getResolved.mockResolvedValue({ - data: { provider_id: 'threatbook-cn-llm', model_id: 'deepseek-v4-flash-0731' }, - }); - mocks.listDefinitions.mockResolvedValue({ data: { models: [routerModel], total: 1 } }); - - renderWithRouter(); - - await waitFor(() => { - expect(mocks.listDefinitions).toHaveBeenCalledWith({ - provider: 'threatbook-cn-llm', - refresh: true, - }); - }); - await user.click(await screen.findByText('DeepSeek-V4-Flash-0731')); - - expect(await screen.findByText('form.routerPricingManaged')).toBeInTheDocument(); - expect(screen.getByText(/form.priceVersion: 2026081405/)).toBeInTheDocument(); - expect(screen.getByText('≤ 100,000')).toBeInTheDocument(); - expect(screen.getByText('100,000 < Token ≤ 10,000,000')).toBeInTheDocument(); - expect(screen.getByText('10,000,000 < Token ≤ 100,000,000')).toBeInTheDocument(); - expect(screen.getByText('> 100,000,000')).toBeInTheDocument(); - expect(screen.getAllByText('¥1')).toHaveLength(2); - expect(screen.getAllByText('¥2')).toHaveLength(3); - expect(screen.getByText('¥3')).toBeInTheDocument(); - expect(screen.getByText('¥4')).toBeInTheDocument(); - expect(screen.getByText('¥6')).toBeInTheDocument(); - - const inputPrice = screen.getByText('form.input').parentElement?.querySelector('input'); - const outputPrice = screen.getByText('form.output').parentElement?.querySelector('input'); - const currencySelect = screen.getByText('form.currency').parentElement?.querySelector('select'); - expect(inputPrice).toHaveAttribute('readonly'); - expect(outputPrice).toHaveAttribute('readonly'); - expect(currencySelect).toBeDisabled(); - }); - it('shows and saves cache-read pricing in model details', async () => { const user = userEvent.setup(); mocks.listDefinitions.mockResolvedValue({ diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index df03d9ee3..5fcac4d96 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -24,7 +24,7 @@ import { customAPI, modelSettingsAPI, catalogAPI, defaultModelAPI, } from '@/api/provider'; import { hasPendingProviderCredentialChanges } from './providerCredentialUtils'; -import { formatPriceTiers, formatPricingPerMillion, isPricingFree } from '@/utils/modelPricing'; +import { formatPricingPerMillion, isPricingFree } from '@/utils/modelPricing'; import { convertCurrencyAmount, formatTokenMillions, @@ -36,7 +36,6 @@ import type { ProviderCredentials, ModelDefinitionV2, UsageStats, CatalogProvider, CatalogModel, CatalogCredentialField, ModelSettingV2, CustomModelCreate, ProviderCredentialInput, FallbackModelRef, - PriceConfigV2, } from '@/types'; // ==================== Provider Auth Helpers ==================== @@ -86,25 +85,6 @@ function convertEditablePrice( return String(Number(converted.toFixed(precision))); } -function pricingCurrencySymbol(currency: string): string { - if (currency === 'CNY') return '¥'; - if (currency === 'USD') return '$'; - return `${currency} `; -} - -function formatTierTokenRange( - tiers: NonNullable, - index: number, -): string { - const previousMax = index === 0 ? null : tiers[index - 1]?.max_input_tokens; - const currentMax = tiers[index]?.max_input_tokens; - if (currentMax == null) { - return previousMax == null ? 'All' : `> ${previousMax.toLocaleString('en-US')}`; - } - if (previousMax == null) return `≤ ${currentMax.toLocaleString('en-US')}`; - return `${previousMax.toLocaleString('en-US')} < Token ≤ ${currentMax.toLocaleString('en-US')}`; -} - // ==================== Connection Cache ==================== const CONNECTION_CACHE_KEY = 'flocks_provider_connection_cache'; @@ -302,7 +282,7 @@ export default function ModelPage() { try { const modelsRes = await modelV2API - .listDefinitions({ provider: provider.id, refresh: true }) + .listDefinitions({ provider: provider.id }) .catch(() => ({ data: { models: [], total: 0 } })); if (providerLoadSeqRef.current !== requestSeq) return; @@ -1053,32 +1033,6 @@ function ProviderDetail({ // ==================== Model Card (V2) ==================== -function PricingSummary({ pricing }: { - pricing: PriceConfigV2 | NonNullable; -}) { - const { t } = useTranslation('model'); - const tierCount = pricing.price_tiers?.length ?? 0; - const tierDetails = tierCount > 0 ? formatPriceTiers(pricing) : ''; - const title = [ - pricing.price_version ? `v${pricing.price_version}` : '', - tierDetails, - ].filter(Boolean).join('\n'); - - return ( - - {formatPricingPerMillion(pricing)} - {tierCount > 0 && ( - - {t('status.tieredPricing', { count: tierCount })} - - )} - - ); -} - function ModelCard({ model, enabled, testStatus, onOpenDetail, onTestModel, onToggle, onDelete }: { model: ModelDefinitionV2; enabled: boolean; @@ -1145,7 +1099,7 @@ function ModelCard({ model, enabled, testStatus, onOpenDetail, onTestModel, onTo {contextK && {contextK}} {pricing && !isPricingFree(pricing) && ( - + {formatPricingPerMillion(pricing)} )} {pricing && isPricingFree(pricing) && ( {t('status.free')} @@ -1235,8 +1189,6 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { const [apiKey, setApiKey] = useState(''); const [showApiKey, setShowApiKey] = useState(false); const [baseUrl, setBaseUrl] = useState(''); - const [modelCatalogUrl, setModelCatalogUrl] = useState(''); - const [modelCatalogSessionToken, setModelCatalogSessionToken] = useState(''); const [description, setDescription] = useState(''); const [providerName, setProviderName] = useState(''); const [azureDeploymentName, setAzureDeploymentName] = useState(''); @@ -1321,8 +1273,6 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { if (provider) { setDisplayName(provider.name); setBaseUrl(provider.default_base_url || ''); - setModelCatalogUrl(provider.default_model_catalog_url || ''); - setModelCatalogSessionToken(''); setApiKey(''); setDescription(provider.description || ''); setSelectedModelIds(new Set(provider.models.map(m => m.id))); @@ -1367,12 +1317,6 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { await providerAPI.setCredentials(selectedCatalogId, { api_key: apiKey.trim(), base_url: baseUrl.trim() || undefined, - model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' - ? modelCatalogUrl.trim() - : undefined, - model_catalog_session_token: selectedCatalogId === 'threatbook-cn-llm' - ? (modelCatalogSessionToken.trim() || undefined) - : undefined, provider_name: selectedCatalogId === 'openai-compatible' && providerName.trim() ? providerName.trim() : undefined, }); const azureModelId = isAzureProviderId(selectedCatalogId) ? azureDeploymentName.trim() : ''; @@ -1427,12 +1371,6 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { await providerAPI.setCredentials(selectedCatalogId, { api_key: apiKey.trim() || 'not-needed', base_url: baseUrl.trim() || undefined, - model_catalog_url: selectedCatalogId === 'threatbook-cn-llm' - ? modelCatalogUrl.trim() - : undefined, - model_catalog_session_token: selectedCatalogId === 'threatbook-cn-llm' - ? (modelCatalogSessionToken.trim() || undefined) - : undefined, }); if (selectedCatalog) { const unselected = selectedCatalog.models.filter(m => !selectedModelIds.has(m.id)).map(m => m.id); @@ -1756,40 +1694,6 @@ function AddProviderDialog({ connectedIds, onClose, onAdded }: { /> - {selectedCatalogId === 'threatbook-cn-llm' && ( - <> -
- - setModelCatalogUrl(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder={selectedCatalog.default_model_catalog_url || 'https://router.example.com/api/console/common/models'} - /> -

{t('form.modelCatalogUrlHint')}

-
-
- - setModelCatalogSessionToken(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder={t('form.modelCatalogSessionPlaceholder')} - autoComplete="off" - /> -

{t('form.modelCatalogSessionHint')}

-
- - )} -
- {provider.id === 'threatbook-cn-llm' && ( - <> -
- - setModelCatalogUrl(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder="https://router.example.com/api/console/common/models" - /> -

{t('form.modelCatalogUrlHint')}

-
-
- - setModelCatalogSessionToken(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 text-sm" - placeholder={hasExistingModelCatalogSession - ? t('form.modelCatalogSessionKeepExisting') - : t('form.modelCatalogSessionPlaceholder')} - autoComplete="off" - /> -

{t('form.modelCatalogSessionHint')}

-
- - )} - {/* API Key */}
- {/* Router prices are authoritative; other providers remain editable. */} + {/* 价格 — 可编辑 */}
- {isRouterManagedPricing && ( -
- {t('form.routerPricingManaged')} - {model.pricing?.price_version && ( - - {t('form.priceVersion')}: {model.pricing.price_version} - - )} -
- )}
- setInputPrice(e.target.value)} - className={isRouterManagedPricing ? inputClsReadOnly : inputCls} - /> + setInputPrice(e.target.value)} className={inputCls} />
- setOutputPrice(e.target.value)} - className={isRouterManagedPricing ? inputClsReadOnly : inputCls} - /> + setOutputPrice(e.target.value)} className={inputCls} />
@@ -3164,20 +2987,14 @@ function ModelDetailSheet({ type="number" step="0.01" value={cacheReadPrice} - readOnly={isRouterManagedPricing} onChange={e => setCacheReadPrice(e.target.value)} - className={isRouterManagedPricing ? inputClsReadOnly : inputCls} + className={inputCls} placeholder="—" />
- handleCurrencyChange(e.target.value)} className={inputCls}> {currency !== 'USD' && currency !== 'CNY' && ( )} @@ -3186,25 +3003,6 @@ function ModelDetailSheet({
- {priceTiers.length > 0 && ( -
-
- {t('form.inputTokenRange')} - {t('form.inputUnitPrice')} - {t('form.outputUnitPrice')} -
- {priceTiers.map((tier, index) => ( -
- {formatTierTokenRange(priceTiers, index)} - {priceSymbol}{tier.input_price} - {priceSymbol}{tier.output_price} -
- ))} -
- )}
{/* 启用此模型 — 可编辑 */} @@ -3280,9 +3078,7 @@ function formatModelPricing( const pricing = model.pricing; if (!pricing) return unavailableLabel; if (isPricingFree(pricing)) return freeLabel; - const base = formatPricingPerMillion(pricing); - const tiers = formatPriceTiers(pricing); - return tiers ? `${base}\n${tiers}` : base; + return formatPricingPerMillion(pricing); } function ModelSelectionInfo({ model }: { model: ModelDefinitionV2 }) { diff --git a/webui/src/pages/Model/providerCredentialUtils.test.ts b/webui/src/pages/Model/providerCredentialUtils.test.ts index 8c6464fa6..0a5808ce0 100644 --- a/webui/src/pages/Model/providerCredentialUtils.test.ts +++ b/webui/src/pages/Model/providerCredentialUtils.test.ts @@ -21,40 +21,6 @@ describe('hasPendingProviderCredentialChanges', () => { ).toBe(true); }); - it('returns true when only the model catalog url changes', () => { - expect( - hasPendingProviderCredentialChanges( - { - apiKey: 'same-key', - baseUrl: 'https://chat.example/v1', - modelCatalogUrl: 'https://router-test.example/models', - }, - { - apiKey: 'same-key', - baseUrl: 'https://chat.example/v1', - modelCatalogUrl: 'https://router-prod.example/models', - }, - ), - ).toBe(true); - }); - - it('returns true when a model catalog session is entered', () => { - expect( - hasPendingProviderCredentialChanges( - { - apiKey: 'same-key', - modelCatalogUrl: 'https://router.example/models', - modelCatalogSessionToken: '', - }, - { - apiKey: 'same-key', - modelCatalogUrl: 'https://router.example/models', - modelCatalogSessionToken: 'new-session', - }, - ), - ).toBe(true); - }); - it('ignores whitespace-only differences', () => { expect( hasPendingProviderCredentialChanges( diff --git a/webui/src/pages/Model/providerCredentialUtils.ts b/webui/src/pages/Model/providerCredentialUtils.ts index a90f8ad33..580445d21 100644 --- a/webui/src/pages/Model/providerCredentialUtils.ts +++ b/webui/src/pages/Model/providerCredentialUtils.ts @@ -1,8 +1,6 @@ export interface ProviderCredentialSnapshot { apiKey: string; baseUrl?: string | null; - modelCatalogUrl?: string | null; - modelCatalogSessionToken?: string | null; } function normalizeValue(value?: string | null): string { @@ -15,8 +13,6 @@ export function hasPendingProviderCredentialChanges( ): boolean { return ( normalizeValue(existing.apiKey) !== normalizeValue(current.apiKey) || - normalizeValue(existing.baseUrl) !== normalizeValue(current.baseUrl) || - normalizeValue(existing.modelCatalogUrl) !== normalizeValue(current.modelCatalogUrl) || - normalizeValue(existing.modelCatalogSessionToken) !== normalizeValue(current.modelCatalogSessionToken) + normalizeValue(existing.baseUrl) !== normalizeValue(current.baseUrl) ); } diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index 4cbf5692d..507682e77 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -419,9 +419,6 @@ export interface ProviderCredentials { secret?: string | null; secret_masked?: string | null; base_url?: string | null; - model_catalog_url?: string | null; - model_catalog_session_token_masked?: string | null; - has_model_catalog_session?: boolean; username?: string | null; /** Sensitive entries are masked on reads and must not be resubmitted unchanged. */ fields?: Record; @@ -434,8 +431,6 @@ export interface ProviderCredentialInput { api_key?: string; secret?: string; base_url?: string; - model_catalog_url?: string; - model_catalog_session_token?: string; username?: string; fields?: Record; provider_name?: string; @@ -515,14 +510,6 @@ export interface PriceConfigV2 { cache_write?: number; unit: number; currency: string; - price_tiers?: PriceTierConfigV2[] | null; - price_version?: string | null; -} - -export interface PriceTierConfigV2 { - max_input_tokens?: number | null; - input_price: number; - output_price: number; } export interface ParameterRuleV2 { @@ -659,7 +646,6 @@ export interface CatalogProvider { credential_schemas: CatalogCredentialSchema[]; env_vars: string[]; default_base_url: string | null; - default_model_catalog_url?: string | null; model_count: number; models: CatalogModel[]; allow_multiple?: boolean; @@ -703,8 +689,6 @@ export interface CatalogModel { cache_read?: number; cache_write?: number; currency: string; - price_tiers?: PriceTierConfigV2[] | null; - price_version?: string | null; }; } diff --git a/webui/src/utils/modelPricing.test.ts b/webui/src/utils/modelPricing.test.ts index 56dc13034..44b0142b1 100644 --- a/webui/src/utils/modelPricing.test.ts +++ b/webui/src/utils/modelPricing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { formatPriceTiers, formatPricingPerMillion, isPricingFree } from './modelPricing'; +import { formatPricingPerMillion, isPricingFree } from './modelPricing'; describe('formatPricingPerMillion', () => { it('formats input and output prices', () => { @@ -11,18 +11,6 @@ describe('formatPricingPerMillion', () => { })).toBe('¥1/¥2/M'); }); - it('formats all Router input-token price tiers', () => { - expect(formatPriceTiers({ - input: 4.2, - output: 16.8, - currency: 'CNY', - price_tiers: [ - { max_input_tokens: 512000, input_price: 4.2, output_price: 16.8 }, - { max_input_tokens: null, input_price: 8.4, output_price: 33.6 }, - ], - })).toBe('≤ 512,000: ¥4.2/¥16.8/M\n> 512,000: ¥8.4/¥33.6/M'); - }); - it('includes the cache-read price when configured', () => { expect(formatPricingPerMillion({ input: 1, diff --git a/webui/src/utils/modelPricing.ts b/webui/src/utils/modelPricing.ts index f8f1c293f..baf3faa28 100644 --- a/webui/src/utils/modelPricing.ts +++ b/webui/src/utils/modelPricing.ts @@ -4,21 +4,8 @@ type PricingPerMillion = { cache_read?: number | null; cache_write?: number | null; currency: string; - price_tiers?: Array<{ - max_input_tokens?: number | null; - input_price: number; - output_price: number; - }> | null; }; -function currencySymbol(currency: string): string { - return currency === 'CNY' - ? '¥' - : currency === 'USD' - ? '$' - : `${currency} `; -} - export function isPricingFree(pricing: PricingPerMillion): boolean { return pricing.input === 0 && pricing.output === 0 @@ -27,23 +14,12 @@ export function isPricingFree(pricing: PricingPerMillion): boolean { } export function formatPricingPerMillion(pricing: PricingPerMillion): string { - const symbol = currencySymbol(pricing.currency); + const symbol = pricing.currency === 'CNY' + ? '¥' + : pricing.currency === 'USD' + ? '$' + : `${pricing.currency} `; const prices = [pricing.input, pricing.output]; if (pricing.cache_read != null) prices.push(pricing.cache_read); return `${prices.map(price => `${symbol}${price}`).join('/')}/M`; } - -export function formatPriceTiers(pricing: PricingPerMillion): string { - const tiers = pricing.price_tiers ?? []; - const symbol = currencySymbol(pricing.currency); - let previousMax = 0; - return tiers.map(tier => { - const range = tier.max_input_tokens == null - ? `> ${previousMax.toLocaleString('en-US')}` - : previousMax === 0 - ? `≤ ${tier.max_input_tokens.toLocaleString('en-US')}` - : `${previousMax.toLocaleString('en-US')} < Token ≤ ${tier.max_input_tokens.toLocaleString('en-US')}`; - if (tier.max_input_tokens != null) previousMax = tier.max_input_tokens; - return `${range}: ${symbol}${tier.input_price}/${symbol}${tier.output_price}/M`; - }).join('\n'); -}