;
+}) {
+ 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}
+
+ )}
+
+ )}
+ {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}
-
- )}
-
- )}
- {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');
-}