diff --git a/.env.example b/.env.example index 0596f8f..4c8db64 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ CODEBUDDY2API_MAX_CONCURRENT=64 # CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT=0 # Optional session/attempt tracing; legacy preserves existing upstream header behavior. # CODEBUDDY2API_REQUEST_CONTEXT_MODE=legacy +# Disable only declared model-capability preflight; other safeguards and Intl image merging remain enabled. +# CODEBUDDY2API_MODEL_CAPABILITY_GUARD=true # SQLite audit defaults to the data directory; this optional path enables a separate text log. CODEBUDDY2API_LOG= diff --git a/app/admin_api.py b/app/admin_api.py index 8aaa54e..e81d92f 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -265,7 +265,8 @@ def build_models(): rule = snapshot["models"].get(source, {"public_id": source, "enabled": True, "keep_original": False, "region": None, "profile": None, "credential_ids": []}) models.append({**item, **rule}) - return JSONResponse({"revision": snapshot["revision"], "models": models}) + return JSONResponse({"revision": snapshot["revision"], "models": models, + "model_capability_guard": config.get("model_capability_guard", True)}) return await run_in_threadpool(build_models) def checked_rule(source, data, *, creating=False): diff --git a/app/audit_store.py b/app/audit_store.py index d6038f4..3f294a9 100644 --- a/app/audit_store.py +++ b/app/audit_store.py @@ -53,7 +53,7 @@ def safe_attempt(value: Any) -> dict: if clean is not None: result[key] = clean for key in ("status_code", "duration_ms", "attempt", "retry_after", "max_attempts", "total_tokens", - "attempt_index", "dropped"): + "attempt_index", "dropped", "merged_runs", "merged_messages"): clean = number(value.get(key)) if clean is not None: result[key] = clean diff --git a/app/gateway_management.py b/app/gateway_management.py index de9de7c..64f3c5b 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -123,7 +123,7 @@ def admin_model_inventory(self): facts = {} for account in (self.CONFIG.get("account_catalogs") or {}).values(): for item in self.gateway._usable_models( - self.gateway._account_scope(account, "serves") or []): + self.gateway._effective_account_scope(account, "serves") or []): source = item["id"] row = facts.setdefault(source, {"id": source, "credits": None, "credits_by_profile": {}}) price = self.gateway._multiplier_value(item.get("credits")) @@ -139,7 +139,11 @@ def admin_model_inventory(self): rule = model_policy.rule_for(self.CONFIG, source) preview = self.admin_model_preview(source, rule) target = facts.get(rule["upstream_id"], row) - result.append({**target, **rule, "id": source, "available_credentials": len(preview["candidates"]), + metadata = self.gateway.model_capabilities.route_metadata( + self.gateway, rule["upstream_id"], rule=rule, include_disabled=True) + prices = self.gateway.model_capabilities.declaration_prices(self.gateway, metadata) + result.append({**target, **metadata, **prices, **rule, "id": source, + "available_credentials": len(preview["candidates"]), "available": bool(preview["candidates"])}) return sorted(result, key=lambda row: row["id"]) @@ -168,7 +172,7 @@ def admin_model_preview(self, source, rule): reason = "后端模型暂时不可用" elif not pool._eligible(entry, upstream, rule=rule): account = (self.CONFIG.get("account_catalogs") or {}).get(identity, {}) - models = self.gateway._account_scope(account, "serves") + models = self.gateway._effective_account_scope(account, "serves", model_id=self.gateway._upstream_model(upstream, profile)) if (self.CONFIG.get("account_catalogs") is not None or self.CONFIG.get("model_cache") is not None) and ( models is None or account.get("profile") != profile): reason = "目录尚未就绪" @@ -181,7 +185,9 @@ def admin_model_preview(self, source, rule): rejected.append({**item, "reason": reason}) else: accepted.append(item) - return {"candidates": accepted, "excluded": rejected} + return {"candidates": accepted, "excluded": rejected, + **self.gateway.model_capabilities.route_metadata( + self.gateway, upstream, rule=rule, include_disabled=True)} def admin_apply_settings(self, values): restart = {"host", "port", "auth_file", "auth_dir", "import_dir", "skip_check", "log_db"} diff --git a/app/message_normalization.py b/app/message_normalization.py new file mode 100644 index 0000000..0ed0251 --- /dev/null +++ b/app/message_normalization.py @@ -0,0 +1,56 @@ +"""Preserve image-bearing user runs on international chat backends.""" +from copy import deepcopy + +from fastapi import HTTPException + + +def _has_image(message): + content = message.get("content") + return isinstance(content, list) and any(isinstance(part, dict) and part.get("type") == "image_url" for part in content) + + +def _invalid(): + return HTTPException(status_code=400, detail={"error": { + "type": "invalid_request_error", "code": "image_user_run_not_mergeable", "param": "messages", + "message": "国际后端无法无损归并含图的连续用户消息;请保持消息级属性一致,并使用字符串或内容块数组", + }}) + + +def merge_intl_user_images(body, profile): + """Copy only merged runs; never cross a system, assistant or tool boundary.""" + messages = body.get("messages") + if profile not in ("intl-cli", "intl-work") or not isinstance(messages, list): + return body, 0, 0 + result, runs, merged_messages = [], 0, 0 + index = 0 + while index < len(messages): + message = messages[index] + if not isinstance(message, dict) or message.get("role") != "user": + result.append(message) + index += 1 + continue + end = index + 1 + while end < len(messages) and isinstance(messages[end], dict) and messages[end].get("role") == "user": + end += 1 + run = messages[index:end] + if len(run) < 2 or not any(_has_image(item) for item in run): + result.extend(run) + else: + attributes = {key: value for key, value in run[0].items() if key not in ("role", "content")} + parts = [] + for offset, item in enumerate(run): + if {key: value for key, value in item.items() if key not in ("role", "content")} != attributes: + raise _invalid() + content = item.get("content") + if isinstance(content, str): + content = [{"type": "text", "text": content}] + if not isinstance(content, list) or any(not isinstance(part, dict) for part in content): + raise _invalid() + if offset: + parts.append({"type": "text", "text": "\n\n"}) + parts.extend(deepcopy(content)) + result.append({**deepcopy(attributes), "role": "user", "content": parts}) + runs += 1 + merged_messages += len(run) - 1 + index = end + return ({**body, "messages": result} if runs else body), runs, merged_messages diff --git a/app/model_capabilities.py b/app/model_capabilities.py new file mode 100644 index 0000000..4637dd2 --- /dev/null +++ b/app/model_capabilities.py @@ -0,0 +1,258 @@ +"""Publish safe account-owned model declarations and validate explicit request requirements.""" +from dataclasses import dataclass +import json +import math +from urllib.parse import urlsplit + +from fastapi import HTTPException + +from app.safe_logging import sanitize_log_text + +from app.model_catalog_view import SharedModel +PROFILES = frozenset(("cn-cli", "cn-work", "intl-cli", "intl-work")) +_TEXT = frozenset(("id", "name", "vendor", "description", "descriptionZh", "descriptionEn", "credits", "summary")) +_BOOL = frozenset(("supportsImages", "disabledMultimodal", "supportsToolCall", "supportsReasoning", + "onlyReasoning", "canDisableThinking", "supportsExtra", "isDefault", "disabled")) +_INT = frozenset(("maxInputTokens", "maxOutputTokens", "maxAllowedSize", "top_k")) +_FLOAT = frozenset(("temperature", "top_p", "repetition_penalty")) + + +def _text(value, limit=4096): + return sanitize_log_text(value, limit) if isinstance(value, str) else None + + +def _positive(value): + return value if type(value) is int and 0 < value <= 2**53 - 1 else None + + +def sanitize_model(model): + """Use an explicit schema so future private upstream fields cannot become public by accident.""" + result = {} + if not isinstance(model, dict): + return result + for key in _TEXT: + if isinstance(model.get(key), str): + result[key] = _text(model[key]) + for key in _BOOL: + if type(model.get(key)) is bool: + result[key] = model[key] + for key in _INT: + value = model.get(key) + if type(value) is int and 0 <= value <= 2**53 - 1: + result[key] = value + for key in _FLOAT: + value = model.get(key) + if type(value) in (float, int) and abs(value) <= 2**53 - 1 and math.isfinite(value): + result[key] = value + tags = model.get("tags") + if isinstance(tags, list): + result["tags"] = [_text(tag, 256) for tag in tags[:32] if isinstance(tag, str)] + icon = model.get("iconUrl") + if isinstance(icon, str) and len(icon) <= 2048: + try: + url = urlsplit(icon) + if url.scheme == "https" and url.hostname and not (url.username or url.password or url.query or url.fragment): + result["iconUrl"] = _text(icon, 2048) + except ValueError: + pass + for key, strings, flags, integers, arrays in ( + ("reasoning", ("defaultEffort", "effort", "summary"), ("canDisableThinking",), (), ("supportedEfforts",)), + ("relatedModels", ("lite", "reasoning"), (), (), ()), + ("contextWindow", (), (), ("defaultLength",), ("supportedLengths",)), + ): + raw = model.get(key) + if not isinstance(raw, dict): + continue + child = {} + for field in strings: + if isinstance(raw.get(field), str): + child[field] = _text(raw[field], 256) + for field in flags: + if type(raw.get(field)) is bool: + child[field] = raw[field] + for field in integers: + if _positive(raw.get(field)) is not None: + child[field] = raw[field] + for field in arrays: + values = raw.get(field) + if isinstance(values, list): + child[field] = ([n for n in values[:64] if _positive(n) is not None] + if field == "supportedLengths" else + [_text(n, 64) for n in values[:32] if isinstance(n, str)]) + result[key] = child + return result + + +def capabilities(model): + model = model or {} + reasoning = model.get("reasoning") if isinstance(model.get("reasoning"), dict) else {} + images = model.get("supportsImages") + if model.get("disabledMultimodal") is True: + images = False + disable = [value for value in (model.get("canDisableThinking"), reasoning.get("canDisableThinking")) + if type(value) is bool] + if not disable and model.get("onlyReasoning") is True: + disable = [False] + if model.get("onlyReasoning") is True and True in disable: + disable.append(False) + can_disable = disable[0] if disable and len(set(disable)) == 1 else None + values = {"images": images, "tools": model.get("supportsToolCall"), + "reasoning": model.get("supportsReasoning"), "thinking_disable": can_disable} + return {key: value if type(value) is bool else None for key, value in values.items()} + + +def describe_models(records): + """Keep distinct per-profile variants without exposing account identifiers.""" + grouped = {} + declarations = [] + for profile, model in records: + if profile not in PROFILES: + continue + clean = sanitize_model(model) + if not clean: + clean = {} # Missing declarations must not look like unanimous support. + if clean: + if isinstance(model, SharedModel): + clean["catalog_source"] = {"kind": "shared", "profiles": sorted({p for p, _ in model.catalog_sources})} + origins = [] + for source_profile, raw in model.catalog_sources: + source = {"profile": source_profile, "metadata": sanitize_model(raw)} + if source not in origins: + origins.append(source) + clean["source_variants"] = sorted(origins, key=lambda item: json.dumps(item, sort_keys=True)) + else: + clean["catalog_source"] = {"kind": "direct", "profiles": [profile]} + variants = grouped.setdefault(profile, []) + if clean not in variants: + variants.append(clean) + declarations.append(clean) + summary = {} + for key in ("images", "tools", "reasoning", "thinking_disable"): + values = [capabilities(model)[key] for model in declarations] + known = {value for value in values if value is not None} + summary[key] = ("mixed" if len(known) > 1 else "unknown" if not values or None in values else + "supported" if values[0] else "unsupported") + limits = {} + for key in ("maxInputTokens", "maxOutputTokens"): + values = [_positive(model.get(key)) for model in declarations] + known = {value for value in values if value is not None} + state = "mixed" if len(known) > 1 else "unknown" if not values or None in values else "known" + limits[key] = {"state": state, "value": values[0] if state == "known" else None} + return {"capabilities": summary, "limits": limits, + "metadata_by_profile": {profile: sorted(variants, key=lambda m: json.dumps(m, sort_keys=True)) + for profile, variants in sorted(grouped.items())}} + + +def entry_model(gateway, entry, name): + profile = entry.get("profile") + accounts = gateway.CONFIG.get("account_catalogs") + if accounts is not None or gateway.CONFIG.get("model_cache") is not None: + account = (accounts or {}).get(entry.get("account_key")) or {} + models = gateway._effective_account_scope( + account, "serves", model_id=gateway._upstream_model(name, profile)) if account.get("profile") == profile else [] + else: + models = gateway._models_for_profile(profile, scope="serves", model_id=gateway._upstream_model(name, profile)) + upstream = gateway._upstream_model(name, profile) + return next((model for model in models or [] if model.get("id") == upstream), None) + + +def declaration_prices(gateway, metadata): + prices = {} + for profile, variants in metadata["metadata_by_profile"].items(): + values = [value for model in variants + if (value := gateway._multiplier_value(model.get("credits"))) is not None and math.isfinite(value)] + if values: + prices[profile] = min(values) + return {"credits": min(prices.values(), default=None), "credits_by_profile": prices} + + + +def route_metadata(gateway, name, *, entries=None, rule=None, region=None, include_disabled=False): + pool = gateway.CONFIG.get("cred_pool") + if entries is None: + entries = pool.entries() if pool is not None else [] + records = [] + for entry in entries: + profile = entry.get("profile") + if profile not in PROFILES or not gateway._in_region(profile, region): + continue + if include_disabled: + scope = rule or {} + if (scope.get("region") and not profile.startswith(scope["region"] + "-")) or ( + scope.get("profile") and profile != scope["profile"]) or ( + scope.get("credential_ids") and entry.get("account_key") not in scope["credential_ids"]): + continue + elif not gateway.model_policy.route_allowed(gateway.CONFIG, entry, name, rule=rule): + continue + model = entry_model(gateway, entry, name) + if model is not None or name == "auto": + records.append((profile, model)) + return describe_models(records) + + +@dataclass(frozen=True) +class Requirements: + images: bool = False + tools: bool = False + effort: str | None = None + thinking: str | None = None + max_output: int | None = None + output_param: str = "max_tokens" + image_param: str = "messages" + + @classmethod + def from_request(cls, body, payload=None, protocol="chat"): + messages = body.get("messages") or [] + images = any(isinstance(message, dict) and isinstance(message.get("content"), list) + and any(isinstance(part, dict) and part.get("type") == "image_url" for part in message["content"]) + for message in messages) + tools = bool(body.get("tools")) or any(isinstance(message, dict) and + (message.get("role") == "tool" or message.get("tool_calls")) for message in messages) + effort = body.get("reasoning_effort") + thinking = (payload or {}).get("thinking") if protocol == "messages" else None + thinking = thinking.get("type") if isinstance(thinking, dict) else None + output = body.get("max_tokens") + param = "max_output_tokens" if protocol == "responses" else "max_tokens" + if output is not None and _positive(output) is None: + raise capability_error([("invalid_output_limit", param, "输出上限必须为正整数")]) + if effort is not None and (not isinstance(effort, str) or not effort): + raise capability_error([("invalid_reasoning_effort", "reasoning_effort", "思考强度必须为非空字符串")]) + return cls(images, tools, effort, thinking, output, param, "input" if protocol == "responses" else "messages") + + def violations(self, model): + model = model or {} + caps = capabilities(model) + failures = [] + if self.images and caps["images"] is False: + failures.append(("unsupported_image_input", self.image_param, "当前路由的模型声明不支持图片输入")) + if self.tools and caps["tools"] is False: + failures.append(("unsupported_tools", "tools", "当前路由的模型声明不支持工具调用")) + enabled = self.effort not in (None, "none") or self.thinking in ("enabled", "adaptive") + disabled = self.effort == "none" or self.thinking == "disabled" + if enabled and caps["reasoning"] is False: + failures.append(("unsupported_reasoning", "reasoning_effort", "当前路由的模型声明不支持思考")) + if disabled and caps["thinking_disable"] is False: + failures.append(("reasoning_required", "reasoning_effort", "当前路由的模型声明不能关闭思考")) + reasoning = model.get("reasoning") if isinstance(model.get("reasoning"), dict) else {} + efforts = reasoning.get("supportedEfforts") + if (self.effort not in (None, "none") and isinstance(efforts, list) and (efforts or isinstance(model, SharedModel)) + and all(isinstance(value, str) for value in efforts) and self.effort not in efforts): + failures.append(("unsupported_reasoning_effort", "reasoning_effort", "思考强度不在当前模型声明的选项中")) + maximum = _positive(model.get("maxOutputTokens")) + if maximum is not None and self.max_output is not None and self.max_output > maximum: + failures.append(("model_output_limit", self.output_param, f"请求的输出上限超过当前模型声明的 {maximum} token")) + return failures + + +def capability_error(failures): + unique = list(dict.fromkeys(failures))[:8] + if not unique: + return HTTPException(status_code=503, headers={"Retry-After": "1"}, detail={"error": { + "type": "service_unavailable", "code": "model_capability_not_ready", + "message": "模型目录正在变化,请稍后重试", + }}) + code = unique[0][0] if len(unique) == 1 else "model_capability_mismatch" + return HTTPException(status_code=400, detail={"error": { + "type": "invalid_request_error", "code": code, "param": unique[0][1], + "message": ";".join(item[2] for item in unique), + }}) diff --git a/app/model_catalog_view.py b/app/model_catalog_view.py new file mode 100644 index 0000000..bb68082 --- /dev/null +++ b/app/model_catalog_view.py @@ -0,0 +1,108 @@ +"""Derive a shared international catalog without changing account-owned source caches.""" +from copy import deepcopy +import math +import re + + +INTERNATIONAL = frozenset(("intl-cli", "intl-work")) +_AUTOMATIC = frozenset(("auto", "default-model")) + + +class SharedModel(dict): + """Retain source variants outside JSON data so upstream fields cannot forge provenance.""" + + def __init__(self, values, sources): + super().__init__(values) + self.catalog_sources = tuple(sources) + + +def _rate(value): + if not isinstance(value, str): + return None + match = re.fullmatch(r"x\s*([0-9]+(?:\.[0-9]+)?)\s*(?:credits?)?", value.strip(), re.IGNORECASE) + if not match: + return None + number = float(match.group(1)) + return number if math.isfinite(number) else None + + +def _flag(values, *, restrictive=False): + if any(value is restrictive for value in values): + return restrictive + if all(type(value) is bool for value in values): + return not restrictive + return None + + +def _common_model(sources): + models = [model for _, model in sources] + result = deepcopy(models[0]) + for field in ("supportsImages", "supportsToolCall", "supportsReasoning", "canDisableThinking", + "supportsExtra", "disabledMultimodal", "onlyReasoning"): + value = _flag([model.get(field) for model in models], + restrictive=field in ("disabledMultimodal", "onlyReasoning")) + if value is None: + result.pop(field, None) + else: + result[field] = value + for field in ("maxInputTokens", "maxOutputTokens", "maxAllowedSize"): + values = [model.get(field) for model in models] + if all(type(value) is int and value > 0 for value in values): + result[field] = min(values) + else: + result.pop(field, None) + rates = [_rate(model.get("credits")) for model in models] + if None in rates: + result.pop("credits", None) + else: + result["credits"] = models[max(range(len(rates)), key=rates.__getitem__)]["credits"] + reasons = [model.get("reasoning") if isinstance(model.get("reasoning"), dict) else {} for model in models] + reasoning = {} + disable = _flag([item.get("canDisableThinking") for item in reasons]) + if disable is not None: + reasoning["canDisableThinking"] = disable + efforts = [item.get("supportedEfforts") for item in reasons] + if all(isinstance(values, list) and all(isinstance(value, str) for value in values) for values in efforts): + reasoning["supportedEfforts"] = list(dict.fromkeys(value for value in efforts[0] + if all(value in values for values in efforts))) + for field in ("defaultEffort", "effort", "summary"): + values = [item.get(field) for item in reasons] + if values[0] is not None and all(value == values[0] for value in values): + reasoning[field] = values[0] + if reasoning: + result["reasoning"] = reasoning + else: + result.pop("reasoning", None) + for field in ("isDefault", "relatedModels", "contextWindow", "temperature", "top_p", "top_k", + "repetition_penalty", "name", "vendor", "description", "descriptionZh", + "descriptionEn", "tags", "iconUrl"): + values = [model.get(field) for model in models] + if field == "isDefault" or any(value != values[0] for value in values): + result.pop(field, None) + return SharedModel(result, sources) + + +def share_models(native, profile, sources, *, model_id=None): + """Prefer native IDs, inherit missing international IDs, and preserve readiness and auto routing.""" + if profile not in INTERNATIONAL or native is None: + return native + result = {} + for model in native: + if isinstance(model, dict) and model.get("id") and (model_id is None or model["id"] == model_id): + result.setdefault(model["id"], model) + shared = {} + for source_profile, models in sources: + if source_profile not in INTERNATIONAL: + continue + for model in models or []: + name = model.get("id") + if (not name or name in result or name in _AUTOMATIC or model.get("disabled") + or (model_id is not None and name != model_id) or model.get("supportsToolCall") is not True): + continue + variants = shared.setdefault(name, []) + record = (source_profile, model) + if record not in variants: + variants.append(record) + for name in sorted(shared): + result[name] = _common_model(shared[name]) + return list(result.values()) diff --git a/app/model_policy.py b/app/model_policy.py index 6d0786e..d61f608 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -3,6 +3,7 @@ from fastapi import HTTPException +from app.model_capabilities import route_metadata _request_policy = ContextVar("gateway_model_policy", default=None) @@ -157,7 +158,8 @@ def public_details(gateway, region=None): if accounts is not None: for entry in candidates: profile = entry.get("profile") - for model in gateway._account_scope(accounts.get(entry.get("account_key")) or {}, "serves") or []: + for model in gateway._effective_account_scope( + accounts.get(entry.get("account_key")) or {}, "serves", model_id=gateway._upstream_model(upstream, profile)) or []: if model.get("id") == gateway._upstream_model(upstream, profile): price = gateway._multiplier_value(model.get("credits")) if price is not None: @@ -174,5 +176,6 @@ def public_details(gateway, region=None): if name in owners and owners[name] != source: continue out.append({"id": name, "credits": min(prices.values(), default=None), - "credits_by_profile": prices}) + "credits_by_profile": prices, + **route_metadata(gateway, upstream, entries=candidates, rule=rule, region=region)}) return out diff --git a/app/request_context.py b/app/request_context.py index 2213a23..c13564d 100644 --- a/app/request_context.py +++ b/app/request_context.py @@ -63,10 +63,11 @@ class Attempt: class RequestContext: - def __init__(self, protocol, mode="legacy", headers=()): + def __init__(self, protocol, mode="legacy", headers=(), *, capability_guard=True): self.request_id = uuid.uuid4().hex self.protocol = protocol self.mode = mode + self.capability_guard = capability_guard self._session_headers = tuple(value.decode("latin-1") for key, value in headers if key.lower() == b"x-codebuddy-session-id") if self.scoped else () self.session_key = "scoped:v1:temporary:" + self.request_id @@ -143,7 +144,8 @@ def ensure_context(scope, config=None): if _SCOPE_KEY not in scope: values = config() if callable(config) else config mode = values.get("request_context_mode", "legacy") if isinstance(values, dict) else "legacy" - scope[_SCOPE_KEY] = RequestContext(PATHS[scope["path"]], mode, scope.get("headers", ())) + guard = values.get("model_capability_guard", True) if isinstance(values, dict) else True + scope[_SCOPE_KEY] = RequestContext(PATHS[scope["path"]], mode, scope.get("headers", ()), capability_guard=guard) return scope[_SCOPE_KEY] diff --git a/app/settings.py b/app/settings.py index 51267e6..91466c9 100644 --- a/app/settings.py +++ b/app/settings.py @@ -35,6 +35,7 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "credit_price_usd": _item(0.03, "number", "国际积分单价", minimum=0), "model_catalog_ttl": _item(21600, "integer", "模型目录缓存秒数", minimum=0, maximum=31536000), "model_guard": _item(True, "boolean", "表外模型拦截"), + "model_capability_guard": _item(True, "boolean", "模型能力预检", env="CODEBUDDY2API_MODEL_CAPABILITY_GUARD"), "max_images": _item(16, "integer", "单请求图片上限", env="CODEBUDDY2API_MAX_IMAGES", minimum=0, maximum=10000), "image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]), "max_request_bytes": _item(32 * 1024 * 1024, "integer", "请求字节上限", env="CODEBUDDY2API_MAX_REQUEST_BYTES", minimum=1, maximum=1024**3), diff --git a/converter.py b/converter.py index 11689cd..88ad754 100644 --- a/converter.py +++ b/converter.py @@ -62,6 +62,9 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.inference_resources import (AccountCapacity, InferenceResourcesMiddleware, inference_lifespan, request_resources, release_credential) from app.request_context import SessionIdentifierError, current_context +from app import model_capabilities +from app.message_normalization import merge_intl_user_images +from app.model_catalog_view import INTERNATIONAL as SHARED_INTL_PROFILES, share_models from app.inference_auth import require_api_key from app.content_filter import ContentFilterDetector, is_filter_error from app.request_limits import ImageLimitError, apply_image_policy @@ -721,7 +724,7 @@ def _eligible(self, entry, model, *, region=None, profile=None, rule=None): if identity != entry.get("account_key"): return False account = (CONFIG.get("account_catalogs") or {}).get(identity) or {} - models = _account_scope(account, "serves") + models = _effective_account_scope(account, "serves", model_id=_upstream_model(model, profile)) if account.get("profile") != profile or models is None: return False usable = _usable_models(models) @@ -750,8 +753,8 @@ def _model_free(self, entry, model: str | None, *, profile=None) -> bool: account = (accounts or {}).get(entry.get("account_key")) or {} if account.get("profile") != profile: return False - return _model_free(_account_scope(account, "serves"), model, profile) - return _model_free(_models_for_profile(profile), model, profile) + return _model_free(_effective_account_scope(account, "serves", model_id=_upstream_model(model, profile)), model, profile) + return _model_free(_models_for_profile(profile, model_id=_upstream_model(model, profile)), model, profile) @classmethod def _entry_endpoint(cls, e: dict) -> str | None: @@ -759,11 +762,20 @@ def _entry_endpoint(cls, e: dict) -> str | None: profile = cls._entry_profile(e) return PROFILE_ENDPOINTS.get(profile) if profile else None + @classmethod + def _model_block_key(cls, entry): + endpoint = cls._entry_endpoint(entry) + if endpoint and cls._entry_profile(entry) in SHARED_INTL_PROFILES: + identity = entry.get("account_key") or hashlib.sha256(str(entry.get("id", "")).encode()).hexdigest() + return f"{endpoint}#account:{identity}" + return endpoint + + def _model_servable(self, e: dict, model: str | None) -> bool: """Check backend/model backoff, skipping the check when no model is supplied.""" if not model: return True - endpoint = self._entry_endpoint(e) + endpoint = self._model_block_key(e) if not endpoint: return True return time.time() >= self._blocks.until(endpoint, _block_model(model)) @@ -809,7 +821,7 @@ def _capacity_key(entry): def pick(self, skey: str | None, model: str | None = None, *, region=None, - tried=(), with_capacity=False) -> CredentialManager | None: + tried=(), with_capacity=False, requirements=None) -> CredentialManager | None: """Select a healthy sticky or round-robin credential, preferring eligible zero-rate accounts.""" self._rescan() # Reload and prune acquire their own locks. with self._lock: @@ -819,6 +831,14 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None, if skey: self._sticky.pop(skey, None) return None + if requirements is not None: + free = self._model_free(candidates[0], model) + checked = [(entry, requirements.violations( + model_capabilities.entry_model(sys.modules[__name__], entry, model))) + for entry in candidates if self._model_free(entry, model) == free] + candidates = [entry for entry, failures in checked if not failures] + if not candidates: + raise model_capabilities.capability_error([failure for _, failures in checked for failure in failures]) limit = CONFIG.get("max_inflight_per_account", 0) if with_capacity and limit: free = self._model_free(candidates[0], model) @@ -844,11 +864,12 @@ def pick(self, skey: str | None, model: str | None = None, *, region=None, return e["cm"] def headers_for(self, skey: str | None, model: str | None = None, *, region=None, - with_generation=False, tried=(), with_capacity=False): + with_generation=False, tried=(), with_capacity=False, requirements=None): """Recheck identity and atomically reserve account capacity before sending.""" capacity_race = False + capability_failures = [] for _ in range(max(1, len(self._entries))): - cm = self.pick(skey, model, region=region, tried=tried, with_capacity=with_capacity) + cm = self.pick(skey, model, region=region, tried=tried, with_capacity=with_capacity, requirements=requirements) if cm is None: return None reason = None @@ -867,6 +888,11 @@ def headers_for(self, skey: str | None, model: str | None = None, *, region=None entry = next((entry for entry in self._entries if entry["cm"] is cm), None) if (entry is not None and cm._generation == generation and self._healthy(entry) and self._eligible(entry, model, region=region, profile=profile) and self._model_healthy(entry, model)): + if requirements is not None: + failures = requirements.violations(model_capabilities.entry_model(sys.modules[__name__], entry, model)) + if failures: + capability_failures.extend(failures) + continue if with_capacity: lease = self._capacity.acquire(self._capacity_key(entry), CONFIG.get("max_inflight_per_account", 0), cm, generation) @@ -877,6 +903,8 @@ def headers_for(self, skey: str | None, model: str | None = None, *, region=None return ((cm, generation) if with_generation else cm), headers if capacity_race: raise self._capacity_error() + if capability_failures: + raise model_capabilities.capability_error(capability_failures) return None @staticmethod @@ -910,7 +938,7 @@ def note_status(self, cm: CredentialManager | None, status: int, return not_servable = _parse_not_servable(raw, status) if model else None if not_servable: - self.note_not_servable(cm, model, code=not_servable[0], msg=not_servable[1]) + self.note_not_servable(cm, model, code=not_servable[0], msg=not_servable[1], generation=generation) return if status != 429 or not model: return @@ -950,15 +978,18 @@ def model_cooldown_until(self, model: str | None, *, region=None) -> float | Non return None return min(untils) - def note_not_servable(self, cm, model: str, code: str = "", msg: str = "") -> float: - """Block an unsupported backend/model pair and return its retry time.""" + def note_not_servable(self, cm, model: str, code: str = "", msg: str = "", *, generation=None) -> float: + """Isolate international model rejection by account; retain domestic backend backoff.""" if not model: return 0.0 - entry = next((e for e in self._entries if e["cm"] is cm), None) - endpoint = self._entry_endpoint(entry) if entry else None - if not endpoint: - return 0.0 - row = self._blocks.note(endpoint, _block_model(model), code=code, msg=msg) + with self._lock, (cm._lock if generation is not None else nullcontext()): + if not self._lease_matches(cm, generation): + return 0.0 + entry = next((e for e in self._entries if e["cm"] is cm), None) + endpoint = self._model_block_key(entry) if entry else None + if not endpoint: + return 0.0 + row = self._blocks.note(endpoint, _block_model(model), code=code, msg=msg) until = float(row.get("until") or 0.0) _log(f"[block] 模型 {model} @{endpoint} 官方回 {code}," f"{time.strftime('%m-%d %H:%M', time.localtime(until))} 前不再派发 " @@ -970,7 +1001,7 @@ def note_model_ok(self, cm, model: str) -> bool: if not model: return False entry = next((e for e in self._entries if e["cm"] is cm), None) - endpoint = self._entry_endpoint(entry) if entry else None + endpoint = self._model_block_key(entry) if entry else None return bool(endpoint) and self._blocks.clear(endpoint, _block_model(model)) def model_block_until(self, model: str | None, *, region=None) -> float | None: @@ -981,10 +1012,11 @@ def model_block_until(self, model: str | None, *, region=None) -> float | None: with self._lock: candidates = [e for e in self._entries if self._healthy(e) and (region is None - or _in_region(self._entry_profile(e), region))] - endpoints = {self._entry_endpoint(e) for e in candidates} + or _in_region(self._entry_profile(e), region)) + and model_policy.route_allowed(CONFIG, e, model)] + endpoints = {self._model_block_key(e) for e in candidates} # Unknown catalogs remain potential sources, but cannot authorize dispatch. - capable = {self._entry_endpoint(e) for e in candidates + capable = {self._model_block_key(e) for e in candidates if (profile := self._entry_profile(e)) and profile in _model_profiles(model, profile_region(profile))} accounts = CONFIG.get("account_catalogs") @@ -997,7 +1029,7 @@ def catalog_unknown(entry): return (account.get("profile") != profile or _account_scope(account, "serves") is None) return _catalog_for(profile, "serves") is None - unknown = {self._entry_endpoint(e) for e in candidates if catalog_unknown(e)} + unknown = {self._model_block_key(e) for e in candidates if catalog_unknown(e)} endpoints.discard(None) endpoints &= capable | unknown if not endpoints: @@ -1487,6 +1519,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): "max_collect_bytes": 8 * 1024 * 1024, "max_concurrent": 64, "upstream_keepalive": False, "max_inflight_per_account": 0, "request_context_mode": "legacy", + "model_capability_guard": True, "failover_max": 0, # Credential failovers allowed before the first response byte "retry_write_timeout": False, # Opt-in replay after incomplete writes "usage_daily": None, # Usage aggregated by date and model @@ -1571,7 +1604,7 @@ def _check_admin_auth(authorization: Optional[str], x_api_key: Optional[str]): _check_auth(authorization, x_api_key) -def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()): +def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=(), requirements=None): """Select a fresh credential lease and headers, excluding tried accounts; report unavailable capacity.""" context = current_context() raw_key = context.session_key if context is not None and context.scoped else session_key(payload) @@ -1581,7 +1614,7 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()) if pool is not None: resources = request_resources.get() picked = pool.headers_for(skey, model, region=region, with_generation=True, tried=tried, - with_capacity=resources is not None) + with_capacity=resources is not None, requirements=requirements) if picked is None: until = pool.model_cooldown_until(model, region=region) if until: @@ -1625,19 +1658,41 @@ def _cred_for(payload: dict, model: str | None = None, *, region=None, tried=()) def _route_chat(payload, body, rid, *, tried=()): - """Resolve the account's backend, region and model without changing client URLs.""" - cred, headers = _cred_for(payload, body.get("model"), tried=tried) - profile = profile_for_headers(headers) - routed_model = _upstream_model(body.get("model"), profile) - if routed_model != body.get("model"): - body = {**body, "model": routed_model} - _guard_request_size(body) - url = chat_url_for_headers(headers) - observe_route(public_model=payload.get("model", "auto"), upstream_model=routed_model, - profile=profile, credential=account_key(profile, headers.get("X-User-Id"), - headers.get("X-Enterprise-Id"))) - _log(f"[{rid}] ROUTE | region={profile_region(profile)} | profile={profile} | model={routed_model} | url={url}") - return body, cred, headers, url + """Validate account capabilities and derive each routed body from canonical input.""" + context = current_context() + enabled = context.capability_guard if context is not None else CONFIG.get("model_capability_guard", True) + cred = None + try: + requirements = (model_capabilities.Requirements.from_request( + body, payload, context.protocol if context is not None else "chat") if enabled else None) + cred, headers = _cred_for(payload, body.get("model"), tried=tried, requirements=requirements) + profile = profile_for_headers(headers) + routed_model = _upstream_model(body.get("model"), profile) + if requirements is not None and CONFIG.get("cred_pool") is None: + entry = {"profile": profile, "account_key": account_key( + profile, headers.get("X-User-Id"), headers.get("X-Enterprise-Id"))} + failures = requirements.violations(model_capabilities.entry_model(sys.modules[__name__], entry, body.get("model"))) + if failures: + raise model_capabilities.capability_error(failures) + canonical = body + if routed_model != body.get("model"): + body = {**body, "model": routed_model} + body, merged_runs, merged_messages = merge_intl_user_images(body, profile) + if body is not canonical: + _guard_request_size(body) + if merged_runs: + observe_attempt("intl_image_merge", merged_runs=merged_runs, merged_messages=merged_messages) + url = chat_url_for_headers(headers) + observe_route(public_model=payload.get("model", "auto"), upstream_model=routed_model, + profile=profile, credential=account_key(profile, headers.get("X-User-Id"), + headers.get("X-Enterprise-Id"))) + _log(f"[{rid}] ROUTE | region={profile_region(profile)} | profile={profile} | model={routed_model} | url={url}") + return body, cred, headers, url + except HTTPException as error: + release_credential(cred) + detail = error.detail.get("error", {}) if isinstance(error.detail, dict) else {} + observe_attempt("request_preflight_rejected", code=detail.get("code")) + raise def _note_cred_model_ok(cred, model: str | None) -> None: @@ -2023,7 +2078,7 @@ def invalidate_model_table() -> None: _model_table_cache = {} -def _catalog_for(profile: str, scope: str = "models"): +def _catalog_for(profile: str, scope: str = "models", *, model_id=None): """Return a profile catalog within the requested account scope.""" accounts = CONFIG.get("account_catalogs") if accounts is not None or CONFIG.get("model_cache") is not None: @@ -2033,7 +2088,7 @@ def _catalog_for(profile: str, scope: str = "models"): if entry.get("profile") != profile: continue account = (accounts or {}).get(entry.get("account_key")) or {} - items = _account_scope(account, scope) + items = _effective_account_scope(account, scope, model_id=model_id) if account.get("profile") == profile and items is not None: if models is None: models = [] @@ -2079,14 +2134,37 @@ def _account_scope(account: dict, scope: str = "models") -> list[dict] | None: return picker + [item for item in account.get("serves") or [] if item.get("id") not in seen] -def _models_for_profile(profile: str, configured=None, *, scope: str = "models") -> list[dict]: - models = _catalog_for(profile, scope) +def _shared_catalog_sources(scope="models"): + accounts, pool = CONFIG.get("account_catalogs"), CONFIG.get("cred_pool") + if accounts is not None or CONFIG.get("model_cache") is not None: + return [(entry["profile"], _account_scope(account, scope)) + for entry in (pool.entries() if pool is not None else []) + if entry.get("profile") in SHARED_INTL_PROFILES and model_policy.credential_enabled(CONFIG, entry) + and (account := (accounts or {}).get(entry.get("account_key"))) + and account.get("profile") == entry["profile"] and account.get("models") is not None] + configured = _configured_profiles(None) + return [(profile, _catalog_for(profile, scope)) for profile in sorted(configured & SHARED_INTL_PROFILES)] + + +def _effective_account_scope(account, scope="models", *, model_id=None): + native = _account_scope(account, scope) + profile = account.get("profile") + if profile not in SHARED_INTL_PROFILES or native is None: + return native + return share_models(native, profile, _shared_catalog_sources(scope), model_id=model_id) + + + +def _models_for_profile(profile: str, configured=None, *, scope: str = "models", model_id=None) -> list[dict]: + models = _catalog_for(profile, scope, model_id=model_id) if models is None: # Static fallback is limited to legacy domestic CLI deployments. configured = _configured_profiles(profile_region(profile)) if configured is None else configured return ([{"id": name, "supportsToolCall": True} for name in DEFAULT_MODELS] if CONFIG.get("model_cache") is None and CONFIG.get("account_catalogs") is None and profile == "cn-cli" and configured <= {"cn-cli"} else []) + if profile in SHARED_INTL_PROFILES and CONFIG.get("account_catalogs") is None and CONFIG.get("model_cache") is None: + models = share_models(models, profile, _shared_catalog_sources(scope), model_id=model_id) return _usable_models(models) @@ -2131,7 +2209,7 @@ def _model_profiles(model: str | None, region: str | None = None, configured=Non return profiles supported = {profile for profile in profiles if any(item["id"] == _upstream_model(model, profile) - for item in _models_for_profile(profile, configured, scope="serves"))} + for item in _models_for_profile(profile, configured, scope="serves", model_id=_upstream_model(model, profile)))} if model == "auto" and region == "cn": # WorkBuddy uses its advertised Auto; legacy CLI defaults remain separate. if "cn-work" in configured and "cn-work" in supported: @@ -2191,7 +2269,7 @@ def current_models(region: str | None = None) -> list[str]: account = accounts.get(entry.get("account_key")) or {} if account.get("profile") != profile: continue - models = _usable_models(_account_scope(account, "serves")) + models = _usable_models(_effective_account_scope(account, "serves")) if zero: models = [model for model in models if _free_multiplier(model.get("credits"))] out.extend(model["id"] for model in models) @@ -2220,10 +2298,11 @@ def current_model_details(region: str | None = None) -> list[dict]: if pool is not None: pool._rescan() details: dict[str, dict] = {} + declarations = {} for name in current_models(region): details[name] = {"id": name, "credits": None, "credits_by_profile": {}} if pool is None: - return list(details.values()) + return [{**item, **model_capabilities.describe_models(())} for item in details.values()] with pool._lock: def record(profile: str, item: dict, *, zero: bool) -> None: name = item.get("id") @@ -2231,6 +2310,7 @@ def record(profile: str, item: dict, *, zero: bool) -> None: return if zero and not _free_multiplier(item.get("credits")): return # Empty accounts cannot supply paid model rates. + declarations.setdefault(name, []).append((profile, item)) value = _multiplier_value(item.get("credits")) if value is None: return @@ -2252,7 +2332,7 @@ def record(profile: str, item: dict, *, zero: bool) -> None: account = accounts.get(entry.get("account_key")) or {} if account.get("profile") != profile: continue - for item in _usable_models(_account_scope(account, "serves")): + for item in _usable_models(_effective_account_scope(account, "serves")): record(profile, item, zero=zero) else: configured = _configured_profiles(region) @@ -2263,7 +2343,7 @@ def record(profile: str, item: dict, *, zero: bool) -> None: continue for item in _models_for_profile(profile, configured): record(profile, item, zero=zero_only) - return list(details.values()) + return [{**item, **model_capabilities.describe_models(declarations.get(item["id"], []))} for item in details.values()] def _client_wants_stream(payload: dict) -> bool: @@ -2406,7 +2486,8 @@ def list_models(authorization: Optional[str] = Header(default=None), x_api_key: Optional[str] = Header(default=None, alias="X-Api-Key")): _check_auth(authorization, x_api_key) data = [{"id": item["id"], "object": "model", "created": 1700000000, "owned_by": "codebuddy", - "credits": item["credits"], "credits_by_profile": item["credits_by_profile"]} + "credits": item["credits"], "credits_by_profile": item["credits_by_profile"], + **{key: item[key] for key in ("capabilities", "limits", "metadata_by_profile") if key in item}} for item in model_policy.public_details(sys.modules[__name__])] return {"object": "list", "data": data} @@ -3487,6 +3568,9 @@ def main(): ap.add_argument("--request-context-mode", choices=("legacy", "scoped"), default=os.environ.get("CODEBUDDY2API_REQUEST_CONTEXT_MODE", "legacy"), help="请求上下文:legacy 保持旧会话头,scoped 启用显式会话与逐尝试追踪;默认 legacy") + ap.add_argument("--model-capability-guard", type=_boolean_arg, nargs="?", const=True, + default=os.environ.get("CODEBUDDY2API_MODEL_CAPABILITY_GUARD", "true"), + help="按账号模型声明预检图片、工具、思考和输出上限;false 仅关闭新增能力预检") ap.add_argument("--log-body-limit", type=_nonnegative_int, metavar="BYTES", default=os.environ.get("CODEBUDDY2API_LOG_BODY_LIMIT", "65536"), help="每条正文日志的预览字节上限,默认 64 KiB;0 只记录摘要") @@ -3517,7 +3601,7 @@ def main(): for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", "tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent", "failover_max", "retry_write_timeout", "upstream_keepalive", "max_inflight_per_account", - "request_context_mode"): + "request_context_mode", "model_capability_guard"): CONFIG[key] = getattr(args, key) CONFIG["api_key"] = args.api_key CONFIG["desensitize"] = args.desensitize diff --git a/docker-compose.yml b/docker-compose.yml index 350d070..3bcce7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: CODEBUDDY2API_UPSTREAM_KEEPALIVE: CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT: CODEBUDDY2API_REQUEST_CONTEXT_MODE: + CODEBUDDY2API_MODEL_CAPABILITY_GUARD: CODEBUDDY2API_TOOL_CALL_MAX_RETRY: ${CODEBUDDY2API_TOOL_CALL_MAX_RETRY:-3} CODEBUDDY2API_FAILOVER_MAX: CODEBUDDY2API_RETRY_WRITE_TIMEOUT: diff --git a/docs/advanced.md b/docs/advanced.md index e113989..509be5f 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -26,6 +26,7 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--usd-rate` | `7.15` | CNY per USD for billing conversion | | `--model-catalog-ttl` | `21600` | Model catalog cache TTL, seconds | | `--no-model-guard` | off | Disable the out-of-catalog guard; passthrough is limited to one product profile and still respects disabling, bindings and catalog readiness | +| `--model-capability-guard [true/false]` | `true` | Preflight declared image, tool, reasoning and mapped output limits; changes affect new requests | | `--max-images` | `16` | Total images per request; `0` permits no images | | `--image-policy` | `truncate` | Keep newest images; `error` rejects excess images with 413 | | `--tool-call-max-retry` | `3` | Extra generations after malformed tool calls (each consumes credits); `0` disables retries | @@ -84,6 +85,15 @@ In scoped mode, optionally send `X-Codebuddy-Session-ID`, `metadata.conversation Switch back to `legacy` to restore old behavior for new requests; in-flight requests retain their initial mode. Before source downgrade, remove the new startup option and restore a compatible control-store backup without `request_context_mode`. +### Model declarations and image compatibility + +`/v1/models` retains its existing fields and adds `capabilities`, `limits` and `metadata_by_profile`; management and routing previews expose the same metadata. Capabilities use `supported`, `unsupported`, `mixed` or `unknown`. Limits carry `state` (`known`, `mixed`, `unknown`) and `value`; only unanimous known limits have a numeric value. Per-profile arrays preserve distinct account declarations without identities. Safe descriptions, capabilities, windows, reasoning options, related models and parameter suggestions are allowlisted; credentials, internal configuration and authenticated URLs are excluded. These are upstream declarations, not native-model or measured guarantees; suggestions do not override requests. + +`model_capability_guard` defaults to `true`; use WebUI settings, `--model-capability-guard false` or `CODEBUDDY2API_MODEL_CAPABILITY_GUARD=false` to disable it. Explicit mismatches return 400 before sending, within existing bindings and the current free-first tier; unknown capabilities remain compatible. Requests retain their entry-time switch. Checks cover images, tools/history, declared reasoning options and the `max_tokens` output limit (including mapped Responses `max_output_tokens`); input tokens are not estimated, `max_completion_tokens` is not renamed or checked against this limit, and Anthropic thinking budgets are not converted. Disabling this guard leaves authentication, catalog authorization, capacity and size limits intact. + +Both international profiles merge image-bearing consecutive `user` runs only after routing, preserving content order and image data. Domestic bodies, text-only runs and system/assistant/tool boundaries remain unchanged. Conflicting message attributes or unrepresentable content return `400 / image_user_run_not_mergeable`; final byte limits still apply. This compatibility step remains enabled when capability preflight is disabled; it neither adds retries nor makes a text model natively visual. Downgrading source also requires removing the new startup option and any persisted `model_capability_guard` key using a compatible control-store backup. + + ## APIs and authentication | Client endpoint | Description | @@ -92,7 +102,7 @@ Switch back to `legacy` to restore old behavior for new requests; in-flight requ | `POST /v1/responses` | OpenAI Responses | | `POST /v1/messages` | Anthropic Messages | | `POST /v1/messages/count_tokens` | Compatibility stub; currently returns `{"input_tokens":0}` without counting tokens | -| `GET /v1/models` | Available models and multipliers | +| `GET /v1/models` | Available models, multipliers and safe per-profile declarations | | `GET /v1/dashboard/billing/subscription` | Converted credit totals; `codebuddy_balance_usd` is the remaining balance | | `GET /v1/dashboard/billing/usage` | `total_usage` in cents and daily breakdowns | @@ -143,7 +153,9 @@ The WebUI supports direct uploads; these rules concern path imports through `POS ## Models and scheduling -Select client models from the WebUI or `GET /v1/models`. Catalogs are cached by account/tenant, region, product and client version in `auth/model-catalog.json`, with a default 6-hour TTL. New credentials trigger synchronization; failures retain only the same account's trusted cache. Legacy unscoped catalogs cannot authorize other accounts. +Select client models from the WebUI or `GET /v1/models`. Raw catalogs remain cached by account/tenant, region, product and client version in `auth/model-catalog.json`, with a default 6-hour TTL. New credentials trigger synchronization; failed refreshes retain that account's trusted cache. Legacy unscoped caches do not become international sharing sources. + +International CLI and WorkBuddy use a deduplicated shared view from enabled, catalog-ready international accounts. A target account must have its own synchronized catalog; its existing model declarations win unchanged. Missing IDs inherit shared declarations, retaining `catalog_source` and safe `source_variants`. Conflicting inherited rates use the higher known rate, limits the smaller known value, reasoning options their intersection and differing descriptive fields are omitted; unknown prices never mean free. Domestic catalogs, credentials, balances, bindings and `auto` defaults remain independent. Shared rates are catalog references, not billing or permission guarantees. Each `/v3/config` refresh caches the agent picker subset as `models` and the account root table as `serves`. Routing and `GET /v1/models` merge these candidates, with picker metadata winning @@ -166,9 +178,8 @@ Credential domain / token issuer determine the product identity. Chat and refres | `intl-cli` | `https://www.codebuddy.ai` | | `intl-work` | `https://www.workbuddy.ai` | -- By default, accounts are selected only for models supported by their own trusted catalog - (picker ∪ account root table, see above); catalogs and balances are never borrowed across accounts. Concrete zero-multiplier models take priority, followed by expiring-credit priority, cooldowns and session stickiness. -- Zero-balance accounts leave paid-model rotation but can still serve concrete zero-multiplier models declared by their own catalog; they rejoin once balance recovers. International paid models need a known positive balance, with an exception for concrete zero-multiplier models. +- Domestic accounts use their own trusted catalogs; international accounts use the shared view above. Concrete zero-rate models take priority, followed by credit expiry, cooldowns and session stickiness. Balances are never borrowed. +- Zero-balance accounts leave paid-model rotation but may use concrete zero-rate models in their effective catalog; they rejoin once balance recovers. International paid models require a known positive balance. - `auto` schedules an account's default, not any model. International accounts need positive balance and `default-model` in their catalog; domestic WorkBuddy must declare `auto`, and domestic CLI needs a known nonempty usable catalog. `auto` does not receive the concrete zero-multiplier balance exemption. - WebUI region, product and credential bindings strictly limit candidates; unavailable bindings never fall back to unselected accounts. Disabled models also reject direct requests. Renaming hides the original ID unless you choose to retain it. - Sent requests are not replayed against another account because of account availability or HTTP errors; later requests select again. Pending catalog/credential readiness usually returns 503 with `Retry-After`; unsupported or disabled models return 404. @@ -208,7 +219,7 @@ Credential domain / token issuer determine the product identity. Chat and refres | Local 401 | Client key differs from the gateway key | | Upstream 401 / 403 | Credential-level authentication circuit opens; inspect and log in again in the WebUI | | 429 | Cool down that upstream model on the credential; later requests rebind automatically. All candidates cooling down still returns 429; with `--failover-max` the in-flight request is replayed on another credential instead | -| Upstream `service info not found` (code 11102) | That backend does not serve the model at all: avoid it for the `(backend, model)` pair, route the model to another backend, and return 404 when none has it. Half-open after 6 h, exponential backoff up to 24 h, cleared at once by one successful call; inspect via `GET /admin/model-blocks` | +| Upstream `service info not found` (11102) | Confirmed 400/404 model rejection backs off by `(backend, model)` domestically and `(account, product, model)` internationally. Return 404 when no candidate remains; half-open after 6 h, up to 24 h on repeats, cleared on success. Old international endpoint-wide entries no longer block accounts; inspect via `GET /admin/model-blocks` | | Connection setup failure | Retry once on a fresh connection: `ConnectError` and `ConnectTimeout` fail before the first body byte, so the upstream holds nothing and replaying cannot double-bill | | Post-send disconnect, read timeout or protocol error | No network replay, avoiding duplicate billing; logs include exception type and elapsed time | | Client hangs up before a non-streaming response is ready | The upstream call is cancelled and its concurrency slot returned at once; the request is audited as `cancelled`, never as a completed answer. Streaming already behaves this way | diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 2f1d358..4046a7b 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -26,6 +26,7 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--usd-rate` | `7.15` | 每美元对应人民币金额,用于 billing 折算 | | `--model-catalog-ttl` | `21600` | 模型目录缓存有效期,秒 | | `--no-model-guard` | 关 | 关闭目录外模型的本地拦截;表外透传仅限单产品,不绕过禁用、绑定或目录就绪检查 | +| `--model-capability-guard [true/false]` | `true` | 预检模型声明的图片、工具、思考及已映射输出上限;新请求生效 | | `--max-images` | `16` | 单请求图片总数;`0` 不允许图片 | | `--image-policy` | `truncate` | 保留最新图片;设为 `error` 时超限返回 413 | | `--tool-call-max-retry` | `3` | 工具参数损坏时的额外生成上限(每次都消耗额度);`0` 不重试 | @@ -84,6 +85,15 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` 设回 `legacy` 即恢复新请求的旧行为,在途请求保留入口模式;源码降级前移除新增启动参数,并恢复不含 `request_context_mode` 的兼容控制库备份。 +### 模型声明与图片兼容 + +`/v1/models` 保留原字段,增加 `capabilities`、`limits`、`metadata_by_profile`,管理接口及路由预览同步提供。能力状态为 `supported`、`unsupported`、`mixed`、`unknown`;限制含 `state`(`known`、`mixed`、`unknown`)和 `value`,仅已知且一致时返回数值。按产品保留不同账号的声明版本,但不公开账号身份。白名单覆盖说明、能力、窗口、思考选项、关联模型及参数建议;凭据、内部配置和带认证信息的 URL 不公开。上游声明不等于原生能力或实测保证,参数建议不自动覆盖请求。 + +`model_capability_guard` 默认 `true`,可在 WebUI、`--model-capability-guard false` 或 `CODEBUDDY2API_MODEL_CAPABILITY_GUARD=false` 关闭。仅在现有绑定和当前免费优先范围内筛选,明确不兼容返回 400、不发上游;未知能力兼容放行,每条请求固定入口开关。检查图片、工具及历史、已声明思考选项,以及 `max_tokens` 输出上限(含 Responses 映射的 `max_output_tokens`);不估算输入 token,不对 `max_completion_tokens` 改名或套用该上限,不转换 Anthropic 思考预算。关闭不绕过鉴权、目录授权、容量或大小限制。 + +两种国际产品在选路后归并含图的连续 `user` 段,保留内容顺序和图片数据;国内请求、纯文本段及 system/assistant/tool 边界不变。消息级属性冲突或内容无法无损表达时返回 `400 / image_user_run_not_mergeable`,最终字节限制仍生效。图片兼容不随能力开关关闭,不增加重试,也不让文本模型获得原生视觉。源码降级还需移除新增启动选项,并使用不含 `model_capability_guard` 键的兼容控制库备份。 + + ## API 与鉴权 | 客户端接口 | 说明 | @@ -92,7 +102,7 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` | `POST /v1/responses` | OpenAI Responses | | `POST /v1/messages` | Anthropic Messages | | `POST /v1/messages/count_tokens` | 兼容占位接口,当前固定返回 `{"input_tokens":0}`,不实际计数 | -| `GET /v1/models` | 可用模型及倍率信息 | +| `GET /v1/models` | 可用模型、倍率及按产品区分的安全元数据 | | `GET /v1/dashboard/billing/subscription` | 积分折算额度;`codebuddy_balance_usd` 为剩余余额 | | `GET /v1/dashboard/billing/usage` | 美分计量的 `total_usage` 与按日明细 | @@ -143,7 +153,9 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` ## 模型与调度 -以 WebUI 和 `GET /v1/models` 为客户端选择依据。目录按账号/租户、地域、产品与客户端版本缓存到 `auth/model-catalog.json`,默认有效期 6 小时;新凭据触发同步,失败只保留同一账号的可信旧缓存。旧未隔离的目录不能授权其他账号。 +以 WebUI 和 `GET /v1/models` 为客户端选择依据。原始目录仍按账号/租户、地域、产品与客户端版本缓存到 `auth/model-catalog.json`,默认有效期 6 小时;新凭据触发同步,刷新失败保留该账号的可信旧缓存。旧未隔离缓存不作为国际共享来源。 + +国际 CLI/WorkBuddy 使用已启用、目录已就绪的国际账号生成去重共享视图。目标账号须完成自身目录同步;已有型号保留自己的完整声明,缺失型号才继承,并通过 `catalog_source`、安全 `source_variants` 标明来源。继承声明冲突时倍率取较高值、上限取较小值、思考选项取交集、描述类字段不一致即省略;未知倍率不当零。国内目录、凭据、余额、绑定及 `auto` 默认模型保持独立;共享倍率只是目录参考,不保证权限或实际扣分。 每次 `/v3/config` 刷新将选择器子集缓存为 `models`,账号根表缓存为 `serves`。选路与 `GET /v1/models` 合并这两组候选,同名保留选择器元数据;`disabled` 和 `availableModels` @@ -164,8 +176,8 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` | `intl-cli` | `https://www.codebuddy.ai` | | `intl-work` | `https://www.workbuddy.ai` | -- 默认仅为账号选择自身可信目录(选择器子集 ∪ 账号根表,见上文)支持的模型;目录和余额不跨账号借用。具体零倍率模型优先,其次按积分过期时间、冷却与会话黏绑调度。 -- 零余额账号退出付费模型轮询,仍可使用自身目录明确声明的具体零倍率模型;余额恢复后重新加入。国际付费模型须有已知正余额,具体零倍率模型可以例外。 +- 国内按自身可信目录选路,国际使用上述共享视图;具体零倍率模型优先,其次按积分过期时间、冷却与会话黏绑调度。余额始终不跨账号借用。 +- 零余额账号退出付费模型轮询,仍可使用有效目录中明确零倍率的具体模型;余额恢复后重新加入。国际付费模型须有已知正余额。 - `auto` 是账号默认模型的调度别名,不代表任意模型。国际账号须有正余额且目录声明 `default-model`;国内 WorkBuddy 须声明 `auto`,国内 CLI 须有已知非空可用目录。`auto` 不享受具体零倍率模型的余额豁免。 - WebUI 的地域、产品和凭证绑定严格限制候选账号,不会回退到未选账号。模型禁用后直接请求同样拒绝;改名默认不保留原 ID,只有选择保留时才同时提供旧 ID。 - 已发送请求不会因账号不可用或 HTTP 错误换账号重放;后续请求才重新选路。目录同步或凭证未就绪通常返回带 `Retry-After` 的 503,不支持或禁用的模型返回 404。 @@ -205,7 +217,7 @@ WebUI 可以直接上传文件;以下限制针对 `POST /admin/credentials` | 本地 401 | 客户端密钥与网关不一致 | | 上游 401 / 403 | 凭证级认证熔断;在 WebUI 检查并重新登录 | | 429 | 对该凭证的上游模型冷却,后续请求自动换绑;全部候选都在冷却时仍返回 429。设了 `--failover-max` 时,当前请求就地换凭证重放 | -| 上游 `service info not found`(11102) | 该后端根本不服务这个模型:按 (后端, 模型) 避让,把该模型派给其他后端,全部后端都没有时返回 404。6 小时后半开放行重试,反复命中最长退避 24 小时,一次成功调用即刻解除;可用 `GET /admin/model-blocks` 查看 | +| 上游 `service info not found`(11102) | 明确的 400/404 模型拒绝:国内按(后端、模型)退避,国际按(账号、产品、模型)隔离;无可用候选时返回 404。6 小时后半开,反复命中最长 24 小时,成功即解除。旧国际入口级记录不再拦截账号;用 `GET /admin/model-blocks` 查看 | | 建连失败 | `ConnectError` / `ConnectTimeout` 换新连接重放一次:两者都发生在写下第一个正文字节之前,上游手里什么都没有,重放不会重复计费 | | 发送后断连、读超时、协议错误 | 不做网络重放,避免重复计费;日志记录异常类型与耗时 | | 非流式响应还没成形,客户端就挂断 | 立刻取消这次上游调用并归还并发名额,审计记为 `cancelled`,不会被记成一次已完成的回答;流式本来就是这一行为 | diff --git a/docs/webui.md b/docs/webui.md index 2738e6b..daae124 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -24,9 +24,12 @@ Management is locked without a key. After changing it, sign in again and restart - Reward claims and departures are durably reserved before sending. Stale or failed readbacks block repeat writes across restarts, without time-based expiry; only a reconciled state or definite pre-send cancellation/rejection releases them. Keep `control.sqlite3`. The console shows pending operations and leaves unknown rewards unset. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. - **Models:** add independent mappings with public/upstream IDs and local enablement. Choose either specific accounts or a region with an optional product filter; switching modes clears the opposite binding. Unavailable candidates never cause out-of-scope fallback. + - “Model details” shows descriptions, capability states, token limits, reasoning options and safe raw metadata by product; differences remain separate. Declarations are read-only and do not guarantee native or measured support. Route previews use the draft binding scope. + - International catalogs are shared and deduplicated. Inherited entries show “Shared catalog source” with expandable safe originals; native declarations and shared references remain distinguishable. - **Logs:** filter requests and inspect failed attempts. Closing details or switching log type cancels pending detail loads. Clearing details keeps historical statistics. - **Settings:** edit unlocked options; hot changes apply immediately, while restart-marked settings require a manual restart. Change locked options in the startup configuration; see [configuration precedence](advanced.md). - “Keep tool descriptions” is off by default and works across all three protocols, independently of prompt compaction; see [tool metadata retention](advanced.md#tool-metadata-retention) for configuration and limits. + - “Model capability preflight” defaults to on. Disabling it affects new requests only, not metadata display, international image-run merging or existing security limits; see [model declarations](advanced.md#model-declarations-and-image-compatibility). Clearing **all logs and statistics** is irreversible. Enter the confirmation text shown in the dialog and re-enter the current API key. This does not delete credentials or gateway settings. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 2d99ff2..de0ad32 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -24,9 +24,12 @@ - 奖励领取和派遣发送前持久化预留,回查失败或状态未更新时跨重启阻止重复写入,不按时间自动放行;读回状态已变化或明确未发送/拒绝后才可续办。保留 `control.sqlite3`;界面显示待核验操作,未知奖励金额不补值。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 - **模型路由**:新增模型映射,独立设置对外 ID、上游 ID 与启停;指定账号和指定区域二选一,区域内可筛产品。切换方式清除另一种绑定,候选不可用时不会越界回退。 + - “模型详情”按产品展示说明、能力状态、token 限制、思考选项及安全原始元数据,差异不合并掩盖;声明只读,不代表原生能力或实测保证。编辑中的路由预览按草稿绑定范围展示。 + - 国际模型表合并去重共用;继承条目标明“共享目录来源”并可展开安全原始声明,不把共享参考值当作本产品直接声明。 - **日志审计**:筛选请求、查看失败详情。关闭详情或切换日志类型会取消未完成的详情加载。清空明细会保留历史统计。 - **系统设置**:修改可编辑项;热更新项立即生效,标记为重启生效的设置需手动重启。锁定项在启动配置中修改,优先级见 [进阶参考](advanced.zh-CN.md)。 - “保留工具描述”默认关闭,可为三协议保留工具说明;独立于提示词压缩,配置与限制见 [工具描述保留](advanced.zh-CN.md#工具描述保留)。 + - “模型能力预检”默认开启;关闭只影响新请求,不关闭元数据展示、国际图片归并或已有安全限制,见 [模型声明](advanced.zh-CN.md#模型声明与图片兼容)。 **全部清空日志与统计不可撤销**,需输入弹窗中的确认文字并复核当前 API key;不会删除凭证或网关配置。 diff --git a/tests/test_environment_config.py b/tests/test_environment_config.py index 61ae98e..24d872d 100644 --- a/tests/test_environment_config.py +++ b/tests/test_environment_config.py @@ -118,6 +118,24 @@ def test_invalid_pooling_environment_fails_before_startup(self): self.start(env) + def test_capability_guard_defaults_precedence_and_validation(self): + _, items, config = self.start() + self.assertTrue(config['model_capability_guard']) + self.assertFalse(items['model_capability_guard']['locked']) + _, items, config = self.start(saved={'model_capability_guard': False}) + self.assertFalse(config['model_capability_guard']) + self.assertEqual(items['model_capability_guard']['source'], 'management') + _, items, config = self.start({'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'true'}) + self.assertTrue(config['model_capability_guard']) + self.assertTrue(items['model_capability_guard']['locked']) + _, items, config = self.start({'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'invalid'}, + cli=('--model-capability-guard=false',)) + self.assertFalse(config['model_capability_guard']) + self.assertEqual(items['model_capability_guard']['source'], 'cli') + with self.assertRaises(SystemExit): + self.start({'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'invalid'}) + + def test_request_context_mode_precedence_and_validation(self): _, items, config = self.start(saved={'request_context_mode': 'scoped'}) self.assertEqual(config['request_context_mode'], 'scoped') @@ -167,6 +185,7 @@ def test_compose_forwards_dotenv_limits_and_retries_without_changing_internal_bi 'CODEBUDDY2API_FAILOVER_MAX': '1', 'CODEBUDDY2API_RETRY_WRITE_TIMEOUT': 'true', 'CODEBUDDY2API_UPSTREAM_KEEPALIVE': 'true', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT': '2', 'CODEBUDDY2API_REQUEST_CONTEXT_MODE': 'scoped', + 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'false', 'CODEBUDDY2API_KEEP_TOOL_METADATA': 'false', 'CODEBUDDY_IMPORT_DIR': '/data/auth/incoming'} service = self.compose(values) port = service['ports'][0] @@ -181,7 +200,7 @@ def test_compose_unset_optional_settings_do_not_override_webui(self): service = self.compose({}) for name in ('CODEBUDDY2API_KEEP_TOOL_METADATA', 'CODEBUDDY2API_FAILOVER_MAX', 'CODEBUDDY2API_RETRY_WRITE_TIMEOUT', 'CODEBUDDY2API_UPSTREAM_KEEPALIVE', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT', - 'CODEBUDDY2API_REQUEST_CONTEXT_MODE'): + 'CODEBUDDY2API_REQUEST_CONTEXT_MODE', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD'): self.assertIsNone(service['environment'].get(name)) self.assertEqual(service['ports'][0]['host_ip'], '127.0.0.1') self.assertEqual(service['environment']['CODEBUDDY_IMPORT_DIR'], '/data/auth/imports') diff --git a/tests/test_intl_catalog.py b/tests/test_intl_catalog.py new file mode 100644 index 0000000..8d1dac9 --- /dev/null +++ b/tests/test_intl_catalog.py @@ -0,0 +1,220 @@ +"""Verify shared international declarations without sharing account authority or domestic catalogs.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import converter as gateway +from app.control_store import ControlStore +from app.model_catalog_view import SharedModel, share_models +from app import model_capabilities as caps +import test_api_flow as fixtures +from test_model_capabilities import image_payload, model + + +class CatalogViewTests(unittest.TestCase): + def test_only_missing_international_ids_are_shared_without_mutation(self): + native = [model(supportsImages=False, credits="x1.00")] + sources = [("intl-work", [model(), model(id="extra", maxOutputTokens=1024)])] + before = deepcopy((native, sources)) + merged = share_models(native, "intl-cli", sources) + self.assertIs(merged[0], native[0]) + self.assertIsInstance(merged[1], SharedModel) + self.assertEqual([item["id"] for item in merged], ["shared-model", "extra"]) + self.assertEqual(merged[0]["credits"], "x1.00") + self.assertEqual((native, sources), before) + for domestic in ("cn-cli", "cn-work"): + self.assertIs(share_models(native, domestic, sources), native) + self.assertIsNone(share_models(None, "intl-cli", sources)) + self.assertEqual(len(share_models([], "intl-cli", sources)), 2) + + def test_source_conflicts_are_conservative_and_original_variants_remain_safe(self): + yes = model(maxOutputTokens=4096, credits="x0.00", supportsImages=True, + reasoning={"supportedEfforts": ["low", "high"], "canDisableThinking": True}) + no = model(maxOutputTokens=2048, credits="x0.30", supportsImages=False, name="Kept name", + descriptionZh="来源说明", tags=["a"], + reasoning={"supportedEfforts": ["high"], "canDisableThinking": False}) + yes.update(vendor="shared-vendor") + no.update(vendor="shared-vendor", iconUrl="https://example.invalid/icon.svg") + yes.update(iconUrl=no["iconUrl"]) + no.update(accessToken="private-secret", uid="private-account") + result = share_models([], "intl-cli", [("intl-work", [yes, no])])[0] + self.assertEqual(result["credits"], "x0.30") + self.assertEqual(result["maxOutputTokens"], 2048) + self.assertFalse(result["supportsImages"]) + self.assertEqual(result["reasoning"]["supportedEfforts"], ["high"]) + self.assertNotIn("name", result) # conflicting display text is omitted, not first-source + self.assertNotIn("descriptionZh", result) + self.assertNotIn("tags", result) + self.assertEqual(result["vendor"], "shared-vendor") + self.assertEqual(result["iconUrl"], no["iconUrl"]) + safe = caps.describe_models([("intl-cli", result)]) + text = json.dumps(safe) + self.assertNotIn("private-secret", text) + self.assertNotIn("private-account", text) + declaration = safe["metadata_by_profile"]["intl-cli"][0] + self.assertEqual(declaration["catalog_source"], {"kind": "shared", "profiles": ["intl-work"]}) + self.assertEqual(len(declaration["source_variants"]), 2) + + def test_unknown_prices_and_limits_are_not_synthesized_as_free_or_unbounded(self): + unknown = model(credits=None) + known = model(maxOutputTokens=256, credits="x0.00") + result = share_models([], "intl-cli", [("intl-work", [unknown, known])])[0] + self.assertNotIn("credits", result) + self.assertNotIn("maxOutputTokens", result) + self.assertFalse(gateway._model_free([result], "shared-model", "intl-cli")) + + def test_disjoint_reasoning_options_do_not_become_unrestricted(self): + variants = [model(reasoning={"supportedEfforts": [effort]}) for effort in ("low", "high")] + result = share_models([], "intl-cli", [("intl-work", variants)])[0] + self.assertEqual(result["reasoning"]["supportedEfforts"], []) + for effort in ("low", "high"): + self.assertTrue(caps.Requirements(effort=effort).violations(result)) + + def test_auto_disabled_and_domestic_entries_are_not_inherited(self): + sources = [("intl-work", [model(id="auto"), model(id="default-model"), model(id="disabled", disabled=True), + model(id="non-chat", supportsToolCall=False), model()]), + ("cn-work", [model(id="domestic-only")])] + result = share_models([], "intl-cli", sources) + self.assertEqual([item["id"] for item in result], ["shared-model"]) + self.assertNotIn("isDefault", result[0]) + + def test_provenance_cannot_be_forged_or_leak_duplicate_private_variants(self): + native = model(catalog_source={"kind": "shared", "profiles": ["cn-cli"]}, source_variants=[{"secret": "secret"}]) + data = caps.describe_models([("intl-cli", native)])["metadata_by_profile"]["intl-cli"][0] + self.assertEqual(data["catalog_source"], {"kind": "direct", "profiles": ["intl-cli"]}) + self.assertNotIn("source_variants", data) + sources = [("intl-work", [model(uid="a"), model(uid="b")])] + data = caps.describe_models([("intl-cli", share_models([], "intl-cli", sources)[0])]) + self.assertEqual(len(data["metadata_by_profile"]["intl-cli"][0]["source_variants"]), 1) + + def test_requested_model_does_not_materialize_unrelated_shared_models(self): + models = [model(id=f"model-{i}") for i in range(100)] + result = share_models([], "intl-cli", [("intl-work", models)], model_id="model-42") + self.assertEqual([item["id"] for item in result], ["model-42"]) + + +class RoutingTests(fixtures.GatewayFixture, unittest.TestCase): + def configure(self, cli=None, work=None, balances=None): + self.fx.configure(profiles=("intl-cli", "intl-work"), balances=balances) + self.fx.account_catalogs({"intl-cli": [model(id="native-cli")] if cli is None else cli, + "intl-work": [model(maxOutputTokens=256)] if work is None else work}) + + def bind(self, target="intl-cli"): + store = ControlStore(self.fx.root / "shared-control.sqlite3") + self.addCleanup(store.close) + store.update_model("shared-model", {"profile": target}, store.snapshot()["revision"], {"shared-model"}) + return self.enterContext(patch.dict(gateway.CONFIG, control_store=store)) + + def test_all_protocols_and_modes_use_cli_identity_for_inherited_work_model(self): + self.configure() + original = deepcopy(gateway.CONFIG["account_catalogs"]) + self.bind() + for protocol in fixtures.fixtures.GENERATIONS: + for stream in (False, True): + with self.subTest(protocol=protocol, stream=stream): + self.fx.post_ok(protocol, image_payload(self.fx, protocol, stream=stream), {"intl-cli"}) + self.assertEqual(gateway.CONFIG["account_catalogs"], original) + self.assertEqual(self.fx.pool._capacity._counts, {}) + data = self.fx.client.get("/v1/models").json()["data"] + public = next(item for item in data if item["id"] == "shared-model") + self.assertEqual(set(public["metadata_by_profile"]), {"intl-cli"}) + self.assertEqual(public["credits_by_profile"], {"intl-cli": 0}) + self.assertEqual(public["metadata_by_profile"]["intl-cli"][0]["catalog_source"]["profiles"], ["intl-work"]) + + def test_work_can_inherit_cli_and_both_products_deduplicate_shared_ids(self): + self.configure(cli=[model()], work=[model(id="native-work")]) + self.bind("intl-work") + self.fx.post_ok("chat/completions", self.fx.payload(), {"intl-work"}) + item = caps.entry_model(gateway, self.fx.entries["intl-work"], "shared-model") + self.assertIsInstance(item, SharedModel) + public = self.fx.client.get("/v1/models").json()["data"] + self.assertEqual(sum(item["id"] == "shared-model" for item in public), 1) + + def test_native_image_and_price_declarations_win_over_shared_claims(self): + self.configure(cli=[model(supportsImages=False, credits="x1.00")]) + self.bind() + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.assertFalse(self.fx.pool._model_free(self.fx.entries["intl-cli"], "shared-model")) + + def test_unknown_target_catalog_remains_not_ready(self): + self.configure() + gateway.CONFIG["account_catalogs"][self.fx.entries["intl-cli"]["account_key"]]["models"] = None + self.bind() + response = self.fx.client.post("/v1/chat/completions", json=self.fx.payload()) + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual(self.fx.requests, []) + + def test_balance_is_not_borrowed_from_source_account(self): + self.configure() + self.bind() + for balance in (None, 0): + self.configure(work=[model(credits="x1.00")], balances={"intl-cli": balance, "intl-work": 100}) + response = self.fx.client.post("/v1/chat/completions", json=self.fx.payload()) + self.assertNotEqual(response.status_code, 200) + self.assertEqual(self.fx.requests, []) + + def test_ready_international_accounts_share_models_without_lending_balances(self): + self.fx.add_account("second-cli", "intl-cli") + self.fx.configure(profiles=("intl-cli", "second-cli"), balances={"intl-cli": 100, "second-cli": 0}) + self.fx.account_catalogs({"intl-cli": [model(id="native")], "second-cli": [model(credits="x1.00")]}) + self.fx.post_ok("chat/completions", self.fx.payload(), {"intl-cli"}) + self.assertFalse(self.fx.pool._eligible(self.fx.entries["second-cli"], "shared-model")) + metadata = caps.entry_model(gateway, self.fx.entries["intl-cli"], "shared-model") + self.assertIsInstance(metadata, SharedModel) + + + def test_disabled_deleted_or_misowned_sources_do_not_authorize_shared_models(self): + self.configure() + target = self.fx.entries["intl-cli"] + self.assertIsNotNone(caps.entry_model(gateway, target, "shared-model")) + with patch.object(gateway.model_policy, "credential_enabled", side_effect=lambda config, entry: entry["profile"] != "intl-work"): + self.assertIsNone(caps.entry_model(gateway, target, "shared-model")) + source = gateway.CONFIG["account_catalogs"][self.fx.entries["intl-work"]["account_key"]] + source["profile"] = "cn-work" + self.assertIsNone(caps.entry_model(gateway, target, "shared-model")) + del gateway.CONFIG["account_catalogs"][self.fx.entries["intl-work"]["account_key"]] + self.assertIsNone(caps.entry_model(gateway, target, "shared-model")) + + def test_capabilities_are_rechecked_after_source_changes_during_header_acquisition(self): + self.configure() + self.bind() + cm = self.fx.entries["intl-cli"]["cm"] + original = cm.get_headers + def changed(): + headers = original() + gateway.CONFIG["account_catalogs"][self.fx.entries["intl-work"]["account_key"]]["models"][0]["supportsImages"] = False + return headers + with patch.object(cm, "get_headers", side_effect=changed): + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_bound_inherited_model_rejection_does_not_escape_or_hide_behind_other_product(self): + self.configure() + self.bind() + cm = self.fx.entries["intl-cli"]["cm"] + self.fx.pool.note_status(cm, 404, model="shared-model", + raw=b'{"code":11102,"msg":"service info not found"}') + response = self.fx.client.post("/v1/chat/completions", json=self.fx.payload()) + self.assertEqual(response.status_code, 404, response.text) + self.assertEqual(self.fx.requests, []) + self.assertTrue(self.fx.pool._model_servable(self.fx.entries["intl-work"], "shared-model")) + + + def test_switch_off_preserves_shared_model_selection_and_guard_boundaries(self): + self.configure(work=[model(supportsImages=False)]) + self.bind() + with patch.dict(gateway.CONFIG, model_capability_guard=False): + self.fx.post_ok("chat/completions", image_payload(self.fx, "chat/completions"), {"intl-cli"}) + self.assertFalse(self.fx.pool._eligible(self.fx.entries["intl-work"], "shared-model")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_message_normalization.py b/tests/test_message_normalization.py new file mode 100644 index 0000000..853767e --- /dev/null +++ b/tests/test_message_normalization.py @@ -0,0 +1,154 @@ +"""Verify international image-run normalization without changing domestic requests or replay policy.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import httpx +from fastapi import HTTPException +import converter as gateway +from app.message_normalization import merge_intl_user_images +from app.request_context import RequestContext +import test_api_flow as fixtures +from test_model_capabilities import IMAGE, image_payload, model + + +def user(content, **extras): + return {"role": "user", "content": content, **extras} + + +def images(body): + return [part for message in body["messages"] if isinstance(message.get("content"), list) + for part in message["content"] if part.get("type") == "image_url"] + + +class NormalizationTests(unittest.TestCase): + def test_first_middle_last_and_multiple_images_keep_order_and_original_bytes(self): + for position in range(3): + messages = [user("first"), user("second"), user("third")] + messages[position]["content"] = [{"type": "text", "text": "before"}, deepcopy(IMAGE), + {"type": "text", "text": "after"}] + body = {"messages": messages} + original = deepcopy(body) + for profile in ("intl-cli", "intl-work"): + result, runs, removed = merge_intl_user_images(body, profile) + self.assertEqual((runs, removed), (1, 2)) + self.assertEqual(images(result), images(original)) + self.assertEqual(len(result["messages"]), 1) + self.assertEqual(body, original) + blocks = result["messages"][0]["content"] + self.assertEqual(sum(part.get("text") == "\n\n" for part in blocks), 2) + next(part for part in blocks if part["type"] == "image_url")["image_url"]["detail"] = "high" + self.assertEqual(body, original) + body = {"messages": [user([deepcopy(IMAGE)]), user([deepcopy(IMAGE)])]} + self.assertEqual(len(images(merge_intl_user_images(body, "intl-work")[0])), 2) + + def test_domestic_and_text_only_runs_are_unchanged(self): + body = {"messages": [user([deepcopy(IMAGE)]), user("question")]} + for profile in ("cn-cli", "cn-work", None): + self.assertIs(merge_intl_user_images(body, profile)[0], body) + text = {"messages": [user("a"), user("b")]} + self.assertIs(merge_intl_user_images(text, "intl-cli")[0], text) + + def test_system_assistant_and_tool_boundaries_are_not_crossed(self): + for role in ("system", "assistant", "tool"): + body = {"messages": [user([deepcopy(IMAGE)]), {"role": role, "content": "boundary"}, user("question")]} + self.assertIs(merge_intl_user_images(body, "intl-work")[0], body) + + def test_conflicting_message_attributes_and_unrepresentable_content_are_rejected(self): + for second in (user("text", name="different"), user("text"), user(None, name="same"), user([3], name="same")): + body = {"messages": [user([deepcopy(IMAGE)], name="same"), second]} + with self.assertRaises(HTTPException) as error: + merge_intl_user_images(body, "intl-work") + self.assertEqual(error.exception.status_code, 400) + self.assertNotIn("different", str(error.exception.detail)) + body = {"messages": [user([deepcopy(IMAGE)], name="same"), user("text", name="same")]} + self.assertEqual(merge_intl_user_images(body, "intl-work")[0]["messages"][0]["name"], "same") + + def test_session_fingerprint_remains_bound_to_original_input(self): + body = {"messages": [user([deepcopy(IMAGE)]), user("question")]} + context = RequestContext("chat", "scoped") + context.bind_session(body, body["messages"]) + key = context.session_key + routed = merge_intl_user_images(body, "intl-work")[0] + context.bind_session(body, routed["messages"]) + self.assertEqual(key, context.session_key) + + +class EndpointTests(fixtures.GatewayFixture, unittest.TestCase): + def test_three_protocols_both_modes_only_international_bodies_are_merged(self): + for profile in fixtures.fixtures.PROFILES: + self.fx.configure(profiles=(profile,)) + self.fx.account_catalogs({profile: [model()]}) + for protocol in fixtures.fixtures.GENERATIONS: + for stream in (False, True): + with self.subTest(profile=profile, protocol=protocol, stream=stream): + body = image_payload(self.fx, protocol, stream=stream) + key = "input" if protocol == "responses" else "messages" + body[key].append(user("question")) + original = deepcopy(body) + self.fx.post_ok(protocol, body, {profile}) + sent = json.loads(self.fx.requests[-1].content) + self.assertEqual(sum(m["role"] == "user" for m in sent["messages"]), 1 if profile.startswith("intl") else 2) + self.assertEqual(len(images(sent)), 1) + self.assertEqual(body, original) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_guard_off_does_not_disable_image_normalization(self): + self.fx.configure(profiles=("intl-work",)) + body = image_payload(self.fx, "chat/completions") + body["messages"].append(user("question")) + with patch.dict(gateway.CONFIG, model_capability_guard=False): + self.fx.post_ok("chat/completions", body, {"intl-work"}) + self.assertEqual(sum(m["role"] == "user" for m in json.loads(self.fx.requests[-1].content)["messages"]), 1) + + def test_unmergeable_runs_release_capacity_without_calling_upstream(self): + self.fx.configure(profiles=("intl-work",)) + body = image_payload(self.fx, "chat/completions") + body["messages"][0]["name"] = "one" + body["messages"].append(user("question", name="two")) + with patch.dict(gateway.CONFIG, max_inflight_per_account=1): + response = self.fx.client.post("/v1/chat/completions", json=body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_size_is_rechecked_after_merge_and_failed_lease_is_released(self): + self.fx.configure(profiles=("intl-work",)) + body = image_payload(self.fx, "chat/completions") + body["messages"].append(user("question")) + prepared = gateway._prepare_chat_body(deepcopy(body)) + limit = gateway._guard_request_size(prepared) + self.assertGreater(gateway._guard_request_size(merge_intl_user_images(prepared, "intl-work")[0]), limit) + with patch.dict(gateway.CONFIG, max_request_bytes=limit): + response = self.fx.client.post("/v1/chat/completions", json=body) + self.assertEqual(response.status_code, 413, response.text) + self.assertEqual(self.fx.requests, []) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_international_to_domestic_failover_uses_the_unmerged_canonical_body(self): + for protocol in fixtures.fixtures.GENERATIONS: + for stream in (False, True): + self.fx.configure(profiles=("intl-work", "cn-cli")) + self.fx.account_catalogs({"intl-work": [model()], "cn-cli": [model()]}) + seen = [] + def reply(request): + seen.append((request.headers["x-domain"], json.loads(request.content))) + if len(seen) == 1: + return httpx.Response(429, json={"error": {"message": "synthetic quota", "code": "quota"}}) + return httpx.Response(200, content=fixtures.fixtures.success_sse()) + body = image_payload(self.fx, protocol, stream=stream) + body["input" if protocol == "responses" else "messages"].append(user("question")) + with patch.dict(gateway.CONFIG, failover_max=1), self.responder(reply): + response = self.fx.client.post("/v1/" + protocol, json=body) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual([item[0] for item in seen], ["www.workbuddy.ai", "www.codebuddy.cn"]) + self.assertEqual([sum(m["role"] == "user" for m in item[1]["messages"]) for item in seen], [1, 2]) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_capabilities.py b/tests/test_model_capabilities.py new file mode 100644 index 0000000..d036ff5 --- /dev/null +++ b/tests/test_model_capabilities.py @@ -0,0 +1,207 @@ +"""Verify public model declarations and bounded preflight without real credentials or network calls.""" +from copy import deepcopy +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from fastapi import HTTPException +import converter as gateway +from app import model_capabilities as caps +from app.control_store import ControlStore +from app.request_context import RequestContext, ensure_context +import test_api_flow as fixtures + +IMAGE = {"type": "image_url", "image_url": {"url": "https://example.invalid/image.png", "detail": "low"}} + + +def model(**values): + return {"id": "shared-model", "name": "Example", "credits": "x0.00", "supportsToolCall": True, + "supportsImages": True, "supportsReasoning": True, **values} + + +def image_payload(fx, protocol, *, stream=False): + body = fx.payload(protocol, stream=stream) + if protocol == "responses": + body["input"][0]["content"] = [{"type": "input_image", "image_url": IMAGE["image_url"]["url"]}] + elif protocol == "messages": + body["messages"][0]["content"] = [{"type": "image", "source": {"type": "url", "url": IMAGE["image_url"]["url"]}}] + else: + body["messages"][0]["content"] = [deepcopy(IMAGE)] + return body + + +class MetadataTests(unittest.TestCase): + def test_schema_preserves_safe_fields_without_including_private_extensions(self): + raw = model(descriptionZh="模型说明", descriptionEn="Description", vendor="example", tags=["fast"], + maxInputTokens=8192, maxOutputTokens=1024, maxAllowedSize=8192, isDefault=True, + disabledMultimodal=False, onlyReasoning=True, canDisableThinking=False, supportsExtra=True, + iconUrl="https://example.invalid/model.svg", temperature=0.5, top_p=0.8, top_k=40, + repetition_penalty=1.0, summary="auto", reasoning={"effort": "high", "defaultEffort": "high", + "summary": "auto", "supportedEfforts": ["low", "high"], "canDisableThinking": False}, + relatedModels={"lite": "light", "reasoning": "heavy"}, + contextWindow={"defaultLength": 8192, "supportedLengths": [4096, 8192]}) + original = deepcopy(raw) + raw.update(accessToken="secret-canary", headers={"Authorization": "secret-canary"}, uid="private-account") + raw["reasoning"]["token"] = "secret-canary" + self.assertEqual(caps.sanitize_model(raw), original) + self.assertNotIn("secret-canary", json.dumps(caps.describe_models([("intl-cli", raw)]))) + + def test_secret_strings_urls_and_invalid_numeric_types_are_not_published(self): + raw = model(descriptionZh="Authorization: Bearer synthetic-canary", iconUrl="https://x.invalid/icon?token=secret", + maxInputTokens=True, maxOutputTokens=-1, temperature=float("inf"), tags=["fast", {"token": "secret"}]) + safe = caps.sanitize_model(raw) + self.assertNotIn("synthetic-canary", json.dumps(safe)) + for key in ("iconUrl", "maxInputTokens", "maxOutputTokens", "temperature"): + self.assertNotIn(key, safe) + self.assertEqual(safe["tags"], ["fast"]) + + def test_conflicting_profiles_and_account_variants_are_preserved(self): + yes = model(maxOutputTokens=1024) + no = model(supportsImages=False, maxOutputTokens=512) + data = caps.describe_models([("cn-cli", yes), ("intl-work", no), ("intl-work", yes), ("cn-cli", yes)]) + self.assertEqual(data["capabilities"]["images"], "mixed") + self.assertEqual(data["limits"]["maxOutputTokens"], {"state": "mixed", "value": None}) + self.assertEqual(len(data["metadata_by_profile"]["cn-cli"]), 1) + self.assertEqual(len(data["metadata_by_profile"]["intl-work"]), 2) + self.assertEqual(caps.describe_models([("cn-cli", yes), ("cn-work", {})])["capabilities"]["images"], "unknown") + + def test_explicit_modality_disable_and_conflicting_thinking_flags(self): + self.assertIs(caps.capabilities(model(disabledMultimodal=True))["images"], False) + self.assertIsNone(caps.capabilities(model(onlyReasoning=True, canDisableThinking=True))["thinking_disable"]) + self.assertIsNone(caps.capabilities({"supportsImages": "false"})["images"]) + + def test_requirements_only_enforce_known_declarations_and_mapped_limits(self): + req = caps.Requirements(images=True, tools=True, effort="high", max_output=2048) + self.assertEqual(req.violations({}), []) + errors = req.violations(model(supportsImages=False, supportsToolCall=False, supportsReasoning=False, + maxOutputTokens=1024, reasoning={"supportedEfforts": ["low"]})) + self.assertEqual({e[0] for e in errors}, {"unsupported_image_input", "unsupported_tools", "unsupported_reasoning", + "unsupported_reasoning_effort", "model_output_limit"}) + self.assertEqual(caps.Requirements.from_request({"max_completion_tokens": 999999}).max_output, None) + self.assertTrue(caps.Requirements(effort="none").violations(model(onlyReasoning=True))) + + def test_context_snapshots_guard_for_the_whole_request(self): + config = {"model_capability_guard": False} + scope = {"path": "/v1/chat/completions", "headers": []} + context = ensure_context(scope, config) + config["model_capability_guard"] = True + self.assertIs(ensure_context(scope, config), context) + self.assertIs(context.capability_guard, False) + self.assertIs(RequestContext("chat").capability_guard, True) + + +class EndpointTests(fixtures.GatewayFixture, unittest.TestCase): + def test_four_profiles_three_protocols_and_modes_reject_before_acquiring_capacity(self): + for profile in fixtures.fixtures.PROFILES: + self.fx.configure(profiles=(profile,)) + self.fx.account_catalogs({profile: [model(supportsImages=False)]}) + for protocol in fixtures.fixtures.GENERATIONS: + for stream in (False, True): + with self.subTest(profile=profile, protocol=protocol, stream=stream): + before = len(self.fx.requests) + body = image_payload(self.fx, protocol, stream=stream) + original = deepcopy(body) + response = self.fx.client.post("/v1/" + protocol, json=body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "unsupported_image_input") + self.assertEqual(len(self.fx.requests), before) + self.assertEqual(body, original) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_disabling_only_new_preflight_keeps_model_metadata_and_routing(self): + self.fx.configure(profiles=("intl-cli",)) + self.fx.account_catalogs({"intl-cli": [model(supportsImages=False)]}) + with patch.dict(gateway.CONFIG, model_capability_guard=False): + for protocol in fixtures.fixtures.GENERATIONS: + self.fx.post_ok(protocol, image_payload(self.fx, protocol), {"intl-cli"}) + item = self.fx.client.get("/v1/models").json()["data"][0] + self.assertEqual(item["capabilities"]["images"], "unsupported") + self.assertFalse(item["metadata_by_profile"]["intl-cli"][0]["supportsImages"]) + + def test_unknown_images_are_not_treated_as_false(self): + self.fx.configure(profiles=("cn-cli",)) + unknown = model() + del unknown["supportsImages"] + self.fx.account_catalogs({"cn-cli": [unknown]}) + self.fx.post_ok("chat/completions", image_payload(self.fx, "chat/completions"), {"cn-cli"}) + + def test_capability_filter_cannot_escalate_from_free_to_paid(self): + self.fx.configure(profiles=("cn-cli", "intl-work")) + self.fx.account_catalogs({"intl-work": [model(supportsImages=False)], + "cn-cli": [model(credits="x1.00")]}) + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + + def test_selects_a_compatible_account_without_crossing_strict_binding(self): + self.fx.configure(profiles=("cn-cli", "intl-work")) + self.fx.account_catalogs({"intl-work": [model(supportsImages=False)], "cn-cli": [model()]}) + self.fx.post_ok("chat/completions", image_payload(self.fx, "chat/completions"), {"cn-cli"}) + store = ControlStore(self.fx.root / "capabilities-control.sqlite3") + self.addCleanup(store.close) + store.update_model("shared-model", {"profile": "intl-work"}, store.snapshot()["revision"], {"shared-model"}) + with patch.dict(gateway.CONFIG, control_store=store): + before = len(self.fx.requests) + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(len(self.fx.requests), before) + item = self.fx.client.get("/v1/models").json()["data"][0] + self.assertEqual(set(item["metadata_by_profile"]), {"intl-work"}) + + def test_rechecks_capabilities_before_reserving_capacity(self): + self.fx.configure(profiles=("intl-work",)) + self.fx.account_catalogs({"intl-work": [model()]}) + entry = self.fx.entries["intl-work"] + original = entry["cm"].get_headers + def refresh(): + headers = original() + gateway.CONFIG["account_catalogs"][entry["account_key"]]["models"][0]["supportsImages"] = False + return headers + with patch.object(entry["cm"], "get_headers", side_effect=refresh): + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(self.fx.requests, []) + self.assertEqual(self.fx.pool._capacity._counts, {}) + + def test_pricing_snapshot_race_fails_closed_without_an_empty_error_crash(self): + self.fx.configure(profiles=("intl-work",)) + entry = self.fx.entries["intl-work"] + with patch.object(self.fx.pool, "_candidates", return_value=[entry]), patch.object( + self.fx.pool, "_model_free", side_effect=[True, False]): + with self.assertRaises(HTTPException) as error: + self.fx.pool.pick(None, "shared-model", requirements=caps.Requirements(images=True)) + self.assertEqual(error.exception.status_code, 503) + self.assertEqual(error.exception.detail["error"]["code"], "model_capability_not_ready") + + def test_compatible_but_full_free_account_does_not_spill_into_paid_capacity(self): + self.fx.configure(profiles=("intl-work", "cn-cli")) + self.fx.account_catalogs({"intl-work": [model()], "cn-cli": [model(credits="x1.00")]}) + entry = self.fx.entries["intl-work"] + lease = self.fx.pool._capacity.acquire(entry["account_key"], 1, entry["cm"], entry["cm"]._generation) + self.addCleanup(lease.release) + with patch.dict(gateway.CONFIG, max_inflight_per_account=1): + response = self.fx.client.post("/v1/chat/completions", json=image_payload(self.fx, "chat/completions")) + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual(self.fx.requests, []) + + + def test_output_limit_in_all_protocols_and_anthropic_thinking_requirements(self): + self.fx.configure(profiles=("intl-work",)) + self.fx.account_catalogs({"intl-work": [model(maxOutputTokens=36, onlyReasoning=True)]}) + for protocol in fixtures.fixtures.GENERATIONS: + response = self.fx.client.post("/v1/" + protocol, json=self.fx.payload(protocol)) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "model_output_limit") + body = self.fx.payload("messages") + body.update(max_tokens=32, thinking={"type": "disabled"}) + response = self.fx.client.post("/v1/messages", json=body) + self.assertEqual(response.status_code, 400, response.text) + self.assertEqual(response.json()["error"]["code"], "reasoning_required") + self.assertEqual(self.fx.requests, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_site_blocks.py b/tests/test_model_site_blocks.py index 03d32b8..c1ec278 100644 --- a/tests/test_model_site_blocks.py +++ b/tests/test_model_site_blocks.py @@ -155,6 +155,8 @@ def entry(identifier): for entry in self.pool.entries(): self.by_endpoint[self.pool._entry_endpoint(entry)] = (entry["cm"], entry["id"]) self.assertEqual(set(self.by_endpoint), {DOMESTIC_ENDPOINT, INTL_ENDPOINT}) + self.intl_block_key = next(self.pool._model_block_key(entry) for entry in self.pool.entries() + if entry["profile"] == INTL_PROFILE) self.addCleanup(converter.invalidate_model_table) def cred_for(self, model=MODEL, region=None): @@ -165,7 +167,7 @@ def test_missing_model_is_not_picked_again(self): """Route to an eligible domestic backend after an international unsupported-model response.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) - self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) + self.assertTrue(self.pool._blocks.blocked(self.intl_block_key, MODEL)) self.assertIsNone(self.pool.model_block_until(MODEL)) # Another backend remains eligible. (picked_cm, _generation), _headers = self.cred_for() self.assertIs(picked_cm, self.by_endpoint[DOMESTIC_ENDPOINT][0]) @@ -179,7 +181,7 @@ def test_error_code_field_decodes_to_block(self): self.assertGreater(self.pool._model_fail[(cid, MODEL)], time.time()) cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) - self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) + self.assertTrue(self.pool._blocks.blocked(self.intl_block_key, MODEL)) def test_fast_failure_when_no_backend_serves_it(self): """Return HTTP 404 when every backend lacks the requested model.""" @@ -226,6 +228,25 @@ def add_account(self, domain, uid): self.pool.reload([Path(e["id"]) for e in self.pool.entries()] + [path]) return next(e for e in self.pool.entries() if e["uid"] == uid) + def test_international_rejection_is_isolated_to_one_account_and_model(self): + other = self.add_account("www.codebuddy.ai", "synthetic-intl-other") + cm, _ = self.by_endpoint[INTL_ENDPOINT] + current = next(entry for entry in self.pool.entries() if entry["cm"] is cm) + self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) + self.assertFalse(self.pool._model_servable(current, MODEL)) + self.assertTrue(self.pool._model_servable(other, MODEL)) + self.assertTrue(self.pool._model_servable(current, OTHER_MODEL)) + self.assertTrue(self.pool._healthy(current)) + self.assertIsNone(self.pool.model_block_until(MODEL, region="intl")) + self.assertFalse(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) + + def test_stale_model_rejection_does_not_block_a_new_generation(self): + cm, _ = self.by_endpoint[INTL_ENDPOINT] + self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found"), + generation=cm._generation - 1) + self.assertFalse(self.pool._blocks.blocked(self.intl_block_key, MODEL)) + + def test_same_region_product_without_root_model_does_not_cancel_block(self): self.add_account("www.workbuddy.cn", "synthetic-cn-work") other = [{"id": OTHER_MODEL, "supportsToolCall": True}] @@ -324,9 +345,9 @@ def test_success_clears_the_block(self): """Clear model backoff immediately after a successful response.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model=MODEL, raw=_error_body(11102, "service info not found")) - self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) + self.assertTrue(self.pool._blocks.blocked(self.intl_block_key, MODEL)) self.assertTrue(self.pool.note_model_ok(cm, MODEL)) - self.assertFalse(self.pool._blocks.blocked(INTL_ENDPOINT, MODEL)) + self.assertFalse(self.pool._blocks.blocked(self.intl_block_key, MODEL)) self.assertFalse(self.pool.note_model_ok(cm, MODEL)) def test_blocks_survive_restart(self): @@ -336,16 +357,16 @@ def test_blocks_survive_restart(self): reopened = converter.CredentialPool( [self.root / ("synthetic-" + profile + ".info") for profile in DOMAINS], blocks_path=self.root / "model-site-blocks.json") - self.assertTrue(reopened._blocks.blocked(INTL_ENDPOINT, MODEL)) - self.assertEqual([row["endpoint"] for row in reopened.model_blocks_detail()], [INTL_ENDPOINT]) + self.assertTrue(reopened._blocks.blocked(self.intl_block_key, MODEL)) + self.assertEqual([row["endpoint"] for row in reopened.model_blocks_detail()], [self.intl_block_key]) def test_alias_auto_is_blocked_under_client_name(self): """Track the public auto model despite its international upstream alias.""" cm, _ = self.by_endpoint[INTL_ENDPOINT] self.pool.note_status(cm, 404, model="default-model", raw=_error_body(11102, "service info not found")) - self.assertTrue(self.pool._blocks.blocked(INTL_ENDPOINT, "auto")) - self.assertFalse(self.pool._blocks.blocked(INTL_ENDPOINT, "default-model")) + self.assertTrue(self.pool._blocks.blocked(self.intl_block_key, "auto")) + self.assertFalse(self.pool._blocks.blocked(self.intl_block_key, "default-model")) if __name__ == "__main__": diff --git a/tests/test_region_routing.py b/tests/test_region_routing.py index 7cda846..c1c67c3 100644 --- a/tests/test_region_routing.py +++ b/tests/test_region_routing.py @@ -218,6 +218,7 @@ def post_rejected(self, endpoint, payload, statuses=(404, 503)): def test_original_generation_apis_preserve_shapes_for_all_four_profiles(self): for profile in PROFILES: + self.configure(profiles=(profile,)) for endpoint in GENERATIONS: for stream in (False, True): with self.subTest(profile=profile, endpoint=endpoint, stream=stream): @@ -312,10 +313,10 @@ def test_credits_multiplier_parser_accepts_official_forms(self): for value in ("x0.03", "x0.03 credits", "x0.34 credits", "", "credits", None, 0, {}, "x0.00x"): self.assertFalse(converter._free_multiplier(value), repr(value)) - def test_product_exclusive_models_only_rotate_between_supporting_regions(self): + def test_domestic_product_scope_and_shared_international_candidates(self): for endpoint in GENERATIONS: for product in ("cli", "work"): - expected = {"cn-" + product, "intl-" + product} + expected = {"cn-" + product, "intl-cli", "intl-work"} seen = set() for _ in range(4): request, body = self.post_ok(endpoint, @@ -327,34 +328,36 @@ def test_product_exclusive_models_only_rotate_between_supporting_regions(self): def test_original_root_automatically_selects_international_only_models(self): for endpoint in GENERATIONS: for profile in ("intl-cli", "intl-work"): - self.post_ok(endpoint, self.payload(endpoint, profile + "-only"), {profile}) + self.post_ok(endpoint, self.payload(endpoint, profile + "-only"), {"intl-cli", "intl-work"}) def test_wrong_region_or_product_sticky_is_automatically_rebound(self): for right in PROFILES: - for wrong in set(PROFILES) - {right}: + allowed = {"intl-cli", "intl-work"} if right.startswith("intl-") else {right} + for wrong in set(PROFILES) - allowed: with self.subTest(right=right, wrong=wrong): payload = self.payload(selected_model=right + "-only") old_keys = set(self.pool._sticky) - self.post_ok("chat/completions", payload, {right}) + self.post_ok("chat/completions", payload, allowed) keys = set(self.pool._sticky) - old_keys self.assertEqual(len(keys), 1) key = keys.pop() self.pool._sticky[key] = (self.entries[wrong]["id"], time.time()) - self.post_ok("chat/completions", payload, {right}) - self.assertEqual(self.pool._sticky[key][0], self.entries[right]["id"]) + self.post_ok("chat/completions", payload, allowed) + self.assertIn(self.pool._sticky[key][0], {self.entries[profile]["id"] for profile in allowed}) def test_model_change_rechecks_sticky_region_and_product_availability(self): payload = self.payload(selected_model="cn-cli-only") self.post_ok("chat/completions", payload, {"cn-cli"}) payload["model"] = "intl-work-only" - self.post_ok("chat/completions", payload, {"intl-work"}) + self.post_ok("chat/completions", payload, {"intl-cli", "intl-work"}) def test_repeated_payload_preserves_account_sticky_and_conversation_id(self): for endpoint in GENERATIONS: for profile in PROFILES: payload = self.payload(endpoint, profile + "-only", system="Keep this instruction.") - first, _ = self.post_ok(endpoint, payload, {profile}) - repeat, _ = self.post_ok(endpoint, payload, {profile}) + allowed = {"intl-cli", "intl-work"} if profile.startswith("intl-") else {profile} + first, _ = self.post_ok(endpoint, payload, allowed) + repeat, _ = self.post_ok(endpoint, payload, allowed) self.assertTrue(first.headers["x-conversation-id"]) self.assertEqual(first.headers["x-conversation-id"], repeat.headers["x-conversation-id"]) self.assertEqual(first.headers["x-user-id"], repeat.headers["x-user-id"]) @@ -395,11 +398,12 @@ def test_account_root_models_route_when_the_picker_subset_omits_them(self): for profile in PROFILES: for endpoint in GENERATIONS: with self.subTest(profile=profile, endpoint=endpoint): + allowed = {"intl-cli", "intl-work"} if profile.startswith("intl-") else {profile} request, sent = self.post_ok( - endpoint, self.payload(endpoint, profile + "-root-only"), {profile}) + endpoint, self.payload(endpoint, profile + "-root-only"), allowed) self.assertEqual(sent["model"], profile + "-root-only") - self.assertEqual(request.url.host, HOSTS[profile]) - # Account-root capabilities cannot be borrowed by another account. + self.assertIn(request.url.host, {HOSTS[candidate] for candidate in allowed}) + # Sharing does not invent model names absent from every source. self.post_rejected("chat/completions", self.payload("chat/completions", "no-such-model")) ids = {item["id"] for item in self.client.get("/v1/models").json()["data"]} self.assertIn("cn-cli-root-only", ids, "对外模型表要能报出实际发得出去的模型") @@ -418,7 +422,7 @@ def test_unusable_root_models_are_not_advertised(self): ids = {item["id"] for item in self.client.get("/v1/models").json()["data"]} self.assertNotIn("cn-cli-image", ids) - def test_unknown_or_empty_catalog_never_borrows_other_profile_models(self): + def test_unknown_catalog_cannot_borrow_but_ready_international_catalog_can(self): for unavailable in PROFILES: for missing in (None, []): with self.subTest(profile=unavailable, catalog=missing): @@ -427,7 +431,8 @@ def test_unknown_or_empty_catalog_never_borrows_other_profile_models(self): self.configure(tables=tables) for endpoint in GENERATIONS: self.post_rejected(endpoint, self.payload(endpoint, unavailable + "-only")) - self.post_ok(endpoint, self.payload(endpoint), set(PROFILES) - {unavailable}) + allowed = set(PROFILES) if missing == [] and unavailable.startswith("intl-") else set(PROFILES) - {unavailable} + self.post_ok(endpoint, self.payload(endpoint), allowed) for missing in (None, []): self.configure(tables={profile: missing for profile in PROFILES}) for endpoint in GENERATIONS: @@ -470,7 +475,8 @@ def test_exclusive_model_429_cannot_fallback_to_unsupported_source(self): for profile in PROFILES: self.configure() payload = self.payload(selected_model=profile + "-only") - self.allowed_profiles = {profile} + allowed = {"intl-cli", "intl-work"} if profile.startswith("intl-") else {profile} + self.allowed_profiles = allowed self.response_status = 429 before = len(self.requests) try: @@ -479,7 +485,11 @@ def test_exclusive_model_429_cannot_fallback_to_unsupported_source(self): self.response_status = 200 self.assertEqual(response.status_code, 429, response.text) self.assertEqual(len(self.requests), before + 1) - self.post_rejected("chat/completions", payload, statuses=(429,)) + remaining = allowed - {self.requests[-1].headers["x-user-id"]} + if remaining: + self.post_ok("chat/completions", payload, remaining) + else: + self.post_rejected("chat/completions", payload, statuses=(429,)) self.post_ok("chat/completions", self.payload(), PROFILES) def test_account_cooldown_rebinds_to_supported_other_region(self): @@ -564,16 +574,16 @@ def test_models_does_not_publish_missing_or_unfunded_product_catalog(self): self.assertEqual(response.json()["data"], []) self.assertFalse(self.requests) - def test_same_profile_accounts_cannot_borrow_catalog_or_balance(self): - second = "intl-cli-second" - self.add_account(second, "intl-cli") + def test_domestic_same_profile_accounts_cannot_borrow_catalog_or_balance(self): + second = "cn-cli-second" + self.add_account(second, "cn-cli") for balance in (None, 0, 100): for missing in (None, [], [model("second-only")]): with self.subTest(balance=balance, catalog=missing): - self.configure(profiles=("intl-cli", second), balances={"intl-cli": balance}) - self.account_catalogs({"intl-cli": [model("first-only")], second: missing}) - if balance == 100: - self.post_ok("chat/completions", self.payload(selected_model="first-only"), {"intl-cli"}) + self.configure(profiles=("cn-cli", second), balances={"cn-cli": balance}) + self.account_catalogs({"cn-cli": [model("first-only")], second: missing}) + if balance != 0: # Domestic unknown-balance compatibility is unchanged. + self.post_ok("chat/completions", self.payload(selected_model="first-only"), {"cn-cli"}) else: self.post_rejected("chat/completions", self.payload(selected_model="first-only")) if missing: @@ -583,8 +593,8 @@ def test_same_profile_accounts_cannot_borrow_catalog_or_balance(self): response = self.client.get("/v1/models") self.assertIn(response.status_code, (200, 503), response.text) if response.status_code == 200: - expected = ({"first-only"} if balance == 100 else set()) | ({"second-only"} if missing else set()) - self.assertEqual({m["id"] for m in response.json()["data"]}, expected) + expected = ({"first-only"} if balance != 0 else set()) | ({"second-only"} if missing else set()) + self.assertEqual({m["id"] for m in response.json()["data"]} - {"auto"}, expected) def test_auto_maps_to_each_accounts_declared_default_and_rotates(self): tables = catalogs() @@ -643,6 +653,7 @@ def test_domestic_cli_retains_legacy_auto_without_borrowing_work_default(self): def test_system_is_only_added_when_needed_and_preserves_payload_and_parameters(self): for profile in PROFILES: + self.configure(profiles=(profile,)) for endpoint in GENERATIONS: for system in (None, "Original caller system; preserve verbatim."): with self.subTest(profile=profile, endpoint=endpoint, system=system): @@ -673,6 +684,7 @@ async def original_json(request): self.assertEqual(len(systems), 1) def test_late_system_is_not_overwritten_when_intl_requires_first_system(self): + self.configure(profiles=("intl-cli",)) payload = self.payload(selected_model="intl-cli-only") payload["messages"].append({"role": "system", "content": "Keep this late system intact."}) original = deepcopy(payload) diff --git a/tests/test_webui_integration.py b/tests/test_webui_integration.py index 15f29c7..dd9da9d 100644 --- a/tests/test_webui_integration.py +++ b/tests/test_webui_integration.py @@ -49,6 +49,45 @@ def policy(self, source="shared-model", **kwargs): known_models=[item["id"] for item in self.management.admin_model_inventory()]) return rule + def test_model_metadata_respects_alias_and_draft_scope_without_exposing_identity(self): + tables = {profile: [{**fixtures.model('shared-model', 'x0.00'), 'supportsImages': profile == 'cn-cli', + 'descriptionZh': 'safe-' + profile, 'accessToken': 'metadata-secret-canary', + 'uid': 'private-account'}] for profile in fixtures.PROFILES} + self.account_catalogs(tables) + self.policy(public_id='public-capabilities', profile='intl-work') + published = next(m for m in self.client.get('/v1/models').json()['data'] if m['id'] == 'public-capabilities') + self.assertEqual(set(published['metadata_by_profile']), {'intl-work'}) + self.assertEqual(published['capabilities']['images'], 'unsupported') + self.assertNotIn('metadata-secret-canary', json.dumps(published)) + self.assertNotIn('private-account', json.dumps(published)) + response = self.client.post('/admin/models/shared-model/preview', json={ + 'public_id': 'public-capabilities', 'upstream_id': 'shared-model', 'enabled': True, + 'keep_original': False, 'region': None, 'profile': 'cn-cli', 'credential_ids': []}) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(set(response.json()['metadata_by_profile']), {'cn-cli'}) + self.assertEqual(response.json()['capabilities']['images'], 'supported') + self.policy(public_id='public-capabilities', profile='intl-work', enabled=False) + rows = self.client.get('/admin/models').json()['models'] + managed = next(row for row in rows if row['id'] == 'shared-model') + self.assertFalse(managed['enabled']) + self.assertEqual(set(managed['metadata_by_profile']), {'intl-work'}) + self.assertNotIn('metadata-secret-canary', json.dumps(managed)) + + def test_managed_capability_guard_hot_toggle_does_not_remove_metadata(self): + self.configure(profiles=('intl-work',)) + self.account_catalogs({'intl-work': [{**fixtures.model('shared-model', 'x0.00'), 'supportsImages': False}]}) + body = self.payload(text=[{'type': 'image_url', 'image_url': {'url': 'https://example.invalid/a.png'}}]) + response = self.client.post('/v1/chat/completions', json=body) + self.assertEqual(response.status_code, 400, response.text) + response = self.client.patch('/admin/settings', json={'revision': self.control.snapshot()['revision'], + 'values': {'model_capability_guard': False}}) + self.assertEqual(response.status_code, 200, response.text) + self.post_ok('chat/completions', body, {'intl-work'}) + data = self.client.get('/admin/models').json() + self.assertFalse(data['model_capability_guard']) + self.assertEqual(data['models'][0]['capabilities']['images'], 'unsupported') + + def test_managed_alias_all_protocols_strict_binding_and_response_names(self): identity = self.entries["intl-work"]["account_key"] self.policy(public_id="garden-fast", credential_ids=[identity]) diff --git a/web/e2e/model-capabilities.spec.ts b/web/e2e/model-capabilities.spec.ts new file mode 100644 index 0000000..757be19 --- /dev/null +++ b/web/e2e/model-capabilities.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from "@playwright/test"; + +for (const width of [1440, 375]) { + test(`model declarations, profile variants and preflight settings at ${width}px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 1050 }); + const errors: string[] = []; + const external: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (request.url().includes("example.invalid")) external.push(request.url()); + }); + let enabled = true; + let revision = 1; + await page.route("**/admin/**", async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === "/admin/session") + return route.fulfill({ json: { authenticated: true, csrf_token: "synthetic-csrf" } }); + if (path === "/admin/credentials") return route.fulfill({ json: { credentials: [] } }); + if (path === "/admin/models") + return route.fulfill({ + json: { + revision, + model_capability_guard: enabled, + models: [ + { + id: "upstream", + public_id: "public", + upstream_id: "upstream", + enabled: true, + keep_original: false, + region: null, + profile: null, + credential_ids: [], + credits: 0, + capabilities: { + images: "mixed", + tools: "supported", + reasoning: "supported", + thinking_disable: "unsupported", + }, + limits: { + maxInputTokens: { state: "known", value: 192000 }, + maxOutputTokens: { state: "mixed", value: null }, + }, + metadata_by_profile: { + "cn-cli": [ + { + id: "upstream", + name: "Synthetic model", + descriptionZh: "仅用于浏览器回归的模型说明", + supportsImages: true, + maxOutputTokens: 64000, + iconUrl: "https://example.invalid/icon.svg", + reasoning: { supportedEfforts: ["low", "high"] }, + }, + ], + "intl-cli": [ + { + id: "upstream", + name: "Synthetic model", + supportsImages: true, + catalog_source: { kind: "shared", profiles: ["intl-work"] }, + source_variants: [ + { + profile: "intl-work", + metadata: { descriptionZh: "共享原始声明", credits: "x0.00" }, + }, + ], + }, + ], + "intl-work": [ + { id: "upstream", supportsImages: false, maxOutputTokens: 32000 }, + { id: "upstream", supportsImages: true, maxOutputTokens: 64000 }, + ], + }, + }, + ], + }, + }); + if (path === "/admin/settings") { + if (request.method() === "PATCH") { + expect(request.headers()["x-csrf-token"]).toBe("synthetic-csrf"); + expect(request.postDataJSON()).toEqual({ + revision, + values: { model_capability_guard: false }, + }); + enabled = false; + revision += 1; + } + return route.fulfill({ + json: { + revision, + audit: {}, + items: [ + { + key: "model_capability_guard", + value: enabled, + stored: enabled, + source: "management", + mode: "hot", + type: "boolean", + label: "模型能力预检", + locked: false, + }, + ], + }, + }); + } + return route.fulfill({ status: 404, json: { error: { message: "unknown fixture route" } } }); + }); + await page.goto("/dashboard/models"); + await expect(page.getByText("能力预检:开启")).toBeVisible(); + await expect(page.getByText("图片:因路由而异")).toBeVisible(); + const trigger = page.getByRole("button", { name: "模型详情" }); + await trigger.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("仅用于浏览器回归的模型说明", { exact: true })).toBeVisible(); + await expect(dialog.getByText("可选强度", { exact: true })).toBeVisible(); + await dialog.getByRole("combobox", { name: "查看产品声明" }).selectOption("intl-work"); + await expect(dialog.getByRole("heading", { name: "声明 2" })).toBeVisible(); + await dialog.getByRole("combobox", { name: "查看产品声明" }).selectOption("intl-cli"); + await expect(dialog.getByRole("heading", { name: "共享目录来源" })).toBeVisible(); + await dialog.getByText(/来源版本 1/).click(); + await expect(dialog.getByText("共享原始声明", { exact: true })).toBeVisible(); + await page.screenshot({ path: `test-results/model-capabilities-${width}.png`, fullPage: true }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + ).toBe(true); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.goto("/dashboard/settings"); + const toggle = page.getByRole("checkbox", { name: /模型能力预检/ }); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + const saved = page.waitForResponse( + (response) => + response.url().endsWith("/admin/settings") && response.request().method() === "PATCH", + ); + await page.getByRole("button", { name: "保存更改" }).click(); + expect((await saved).status()).toBe(200); + await expect(toggle).not.toBeChecked(); + await expect(page.getByText(/国际图片归并及其他安全限制不变/)).toBeVisible(); + await page.goto("/dashboard/models"); + await expect(page.getByText("能力预检:关闭")).toBeVisible(); + await expect(page.getByText("图片:因路由而异")).toBeVisible(); + expect(external).toEqual([]); + expect(errors).toEqual([]); + }); +} diff --git a/web/src/api.ts b/web/src/api.ts index e00545e..bb0552c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -178,8 +178,15 @@ export type ModelRule = { credential_ids: string[]; credits?: unknown; credits_by_profile?: unknown; + capabilities?: unknown; + limits?: unknown; + metadata_by_profile?: unknown; }; -export function modelResponse(value: unknown): { revision: number; models: ModelRule[] } { +export function modelResponse(value: unknown): { + revision: number; + models: ModelRule[]; + model_capability_guard?: boolean; +} { const data = object(value); if (typeof data.revision !== "number") throw new Error("模型响应缺少 revision"); const models = list(data.models).map((m) => { @@ -207,7 +214,12 @@ export function modelResponse(value: unknown): { revision: number; models: Model credential_ids: m.credential_ids as string[], }; }); - return { revision: data.revision, models }; + return { + revision: data.revision, + models, + model_capability_guard: + typeof data.model_capability_guard === "boolean" ? data.model_capability_guard : undefined, + }; } export type Credential = RecordValue & { id: string; name: string | null }; export function credentialResponse(value: unknown): Credential[] { diff --git a/web/src/modelMetadata.test.tsx b/web/src/modelMetadata.test.tsx new file mode 100644 index 0000000..0f11c3c --- /dev/null +++ b/web/src/modelMetadata.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { api, type ModelRule } from "./api"; +import { CapabilityBadges, ModelMetadata } from "./modelMetadata"; +import { Models } from "./pages/Models"; +import { Settings } from "./pages/Settings"; + +const model: ModelRule = { + id: "upstream", + public_id: "public", + upstream_id: "upstream", + enabled: true, + keep_original: false, + region: "", + profile: "", + credential_ids: [], + capabilities: { + images: "mixed", + tools: "supported", + reasoning: "supported", + thinking_disable: "unsupported", + }, + limits: { + maxInputTokens: { state: "known", value: 192000 }, + maxOutputTokens: { state: "mixed", value: null }, + }, + metadata_by_profile: { + "cn-cli": [ + { + id: "upstream", + name: "Display model", + descriptionZh: "中文说明", + supportsImages: true, + supportsToolCall: true, + maxInputTokens: 192000, + maxOutputTokens: 64000, + iconUrl: "https://example.invalid/model.svg", + reasoning: { supportedEfforts: ["low", "high"], canDisableThinking: false }, + contextWindow: { defaultLength: 192000, supportedLengths: [64000, 192000] }, + }, + ], + "intl-work": [ + { + id: "upstream", + name: "Display model", + descriptionEn: "English description", + supportsImages: false, + maxOutputTokens: 32000, + }, + { id: "upstream", supportsImages: true, maxOutputTokens: 64000 }, + ], + }, +}; + +afterEach(() => vi.restoreAllMocks()); + +describe("model declarations", () => { + it("distinguishes unknown from unsupported and does not claim measured support", () => { + const { rerender } = render(); + expect(screen.getByText("图片:未知")).toBeTruthy(); + expect(screen.queryByText("图片:不支持")).toBeNull(); + rerender(); + expect(screen.getByText("图片:因路由而异")).toBeTruthy(); + }); + + it("renders complete structured declarations, options and profile variants without loading remote icons", () => { + const { container } = render(); + expect(screen.getByText("中文说明", { selector: "span" })).toBeTruthy(); + expect(screen.getByText("可选强度")).toBeTruthy(); + expect(screen.getByText("可选窗口")).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + fireEvent.change(screen.getByRole("combobox", { name: "查看产品声明" }), { + target: { value: "intl-work" }, + }); + expect(screen.getByText("English description")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "声明 2" })).toBeTruthy(); + expect(screen.queryByText("中文说明", { selector: "span" })).toBeNull(); + }); + + it("keeps upstream markup inert and gracefully handles old API responses", () => { + const value = { + metadata_by_profile: { "intl-cli": [{ descriptionZh: "" }] }, + }; + const { container, rerender } = render(); + expect(screen.getByText("", { selector: "span" })).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + rerender(); + expect(screen.getByText("尚无模型元数据")).toBeTruthy(); + }); + + it("distinguishes inherited declarations and exposes safe source variants", () => { + const inherited = { + metadata_by_profile: { + "intl-cli": [ + { + id: "model", + credits: "x0.3", + catalog_source: { kind: "shared", profiles: ["intl-work"] }, + source_variants: [ + { + profile: "intl-work", + metadata: { descriptionZh: "共享原始声明", credits: "x0.3" }, + }, + ], + }, + ], + }, + }; + const { rerender } = render(); + expect(screen.getByText("国际共享目录")).toBeTruthy(); + expect(screen.getByText(/当前账号未直接声明此模型/)).toBeTruthy(); + fireEvent.click(screen.getByText(/来源版本 1/)); + expect(screen.getByText("共享原始声明", { selector: "span" })).toBeTruthy(); + rerender( + , + ); + expect(screen.getByText("目录来源:当前产品直接声明。")).toBeTruthy(); + expect(screen.queryByText("国际共享目录")).toBeNull(); + rerender(); + expect(screen.queryByText("目录来源:当前产品直接声明。")).toBeNull(); + }); + + it("exposes details from the model table and supports display-name search", async () => { + vi.spyOn(api, "get").mockImplementation( + async (path) => + ({ + data: + path === "/models" + ? { revision: 1, models: [model], model_capability_guard: false } + : { credentials: [] }, + }) as never, + ); + render(); + await screen.findByText("能力预检:关闭"); + fireEvent.change(screen.getByRole("textbox", { name: "搜索模型" }), { + target: { value: "Display" }, + }); + fireEvent.click(screen.getByRole("button", { name: "模型详情" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByRole("combobox", { name: "查看产品声明" })).toBeTruthy(); + expect(within(dialog).getByRole("heading", { name: "能力概要" })).toBeTruthy(); + }); +}); + +describe("capability guard settings", () => { + function settings(locked: boolean) { + return { + revision: 1, + audit: {}, + items: [ + { + key: "model_capability_guard", + value: true, + stored: null, + source: locked ? "environment" : "default", + mode: "hot", + type: "boolean", + label: "模型能力预检", + locked, + }, + ], + }; + } + + it("saves an explicit false without changing other settings", async () => { + vi.spyOn(api, "get").mockResolvedValue({ data: settings(false) }); + const save = vi.spyOn(api, "patch").mockResolvedValue({ data: { revision: 2 } }); + render(); + const toggle = await screen.findByRole("checkbox", { name: /模型能力预检/ }); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole("button", { name: "保存更改" })); + await waitFor(() => + expect(save).toHaveBeenCalledWith("/settings", { + revision: 1, + values: { model_capability_guard: false }, + }), + ); + expect(screen.getByText(/国际图片归并及其他安全限制不变/)).toBeTruthy(); + }); + + it("shows an environment-locked guard instead of an editable switch", async () => { + vi.spyOn(api, "get").mockResolvedValue({ data: settings(true) }); + render(); + await screen.findByText("外部锁定"); + expect(screen.queryByRole("checkbox", { name: /模型能力预检/ })).toBeNull(); + expect(screen.getByText("当前生效:开启")).toBeTruthy(); + }); +}); diff --git a/web/src/modelMetadata.tsx b/web/src/modelMetadata.tsx new file mode 100644 index 0000000..472669e --- /dev/null +++ b/web/src/modelMetadata.tsx @@ -0,0 +1,281 @@ +import { Fragment, useState } from "react"; +import type { ModelRule, RecordValue } from "./api"; +import { Badge, DataValue, Empty, Panel, profileLabel } from "./components"; +import s from "./ui.module.scss"; + +type Facts = Pick; +const record = (value: unknown): RecordValue => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as RecordValue) + : {}; +const names: Record = { + images: "图片", + tools: "工具", + reasoning: "思考", + thinking_disable: "关闭思考", +}; +const states: Record = { + supported: "支持", + unsupported: "不支持", + mixed: "因路由而异", + unknown: "未知", +}; +const labels: Record = { + id: "上游标识", + name: "显示名称", + vendor: "厂商标识", + description: "说明", + descriptionZh: "中文说明", + descriptionEn: "英文说明", + credits: "目录倍率(原值)", + tags: "标签", + isDefault: "上游默认模型", + disabled: "上游停用", + iconUrl: "图标地址(不自动加载)", + supportsImages: "声明支持图片", + disabledMultimodal: "禁用多模态", + supportsToolCall: "声明支持工具", + supportsReasoning: "声明支持思考", + onlyReasoning: "仅思考模式", + canDisableThinking: "允许关闭思考", + supportsExtra: "上游扩展标记", + maxInputTokens: "最大输入 Token", + maxOutputTokens: "最大输出 Token", + maxAllowedSize: "上游预算(原值)", + contextWindow: "上下文窗口", + defaultLength: "默认窗口", + supportedLengths: "可选窗口", + reasoning: "思考选项", + defaultEffort: "默认强度", + effort: "强度", + supportedEfforts: "可选强度", + summary: "摘要建议", + relatedModels: "关联模型", + lite: "普通模式", + temperature: "温度建议", + top_p: "Top P 建议", + top_k: "Top K 建议", + repetition_penalty: "重复惩罚建议", +}; +const groups: [string, string[]][] = [ + [ + "基本信息", + [ + "id", + "name", + "vendor", + "description", + "descriptionZh", + "descriptionEn", + "credits", + "tags", + "isDefault", + "disabled", + "iconUrl", + ], + ], + [ + "能力与限制", + [ + "supportsImages", + "disabledMultimodal", + "supportsToolCall", + "supportsReasoning", + "onlyReasoning", + "canDisableThinking", + "supportsExtra", + "maxInputTokens", + "maxOutputTokens", + "maxAllowedSize", + "contextWindow", + ], + ], + [ + "思考与参数建议", + [ + "reasoning", + "relatedModels", + "temperature", + "top_p", + "top_k", + "repetition_penalty", + "summary", + ], + ], +]; + +export function CapabilityBadges({ model, full = false }: { model: Facts; full?: boolean }) { + const declared = record(model.capabilities); + return ( +
+ {(full ? Object.keys(names) : ["images", "tools", "reasoning"]).map((key) => { + const raw = declared[key]; + const state = typeof raw === "string" && Object.hasOwn(states, raw) ? raw : "unknown"; + return ( + + {names[key]}:{states[state]} + + ); + })} + {variants(model.metadata_by_profile).some(([, rows]) => + rows.some((entry) => record(entry.catalog_source).kind === "shared"), + ) && 国际共享目录} +
+ ); +} + +function MetaFields({ data, depth = 0 }: { data: RecordValue; depth?: number }) { + return ( +
+ {Object.entries(data).map(([key, value]) => ( + +
{Object.hasOwn(labels, key) ? labels[key] : key}
+
+ {value !== null && typeof value === "object" && !Array.isArray(value) && depth < 3 ? ( + + ) : ( + + )} +
+
+ ))} +
+ ); +} + +function variants(value: unknown): [string, RecordValue[]][] { + return Object.entries(record(value)) + .filter(([key]) => ["cn-cli", "cn-work", "intl-cli", "intl-work"].includes(key)) + .map(([profile, values]) => [profile, Array.isArray(values) ? values.map(record) : []]); +} + +export function modelDisplayName(model: Facts): string { + const values = [ + ...new Set( + variants(model.metadata_by_profile) + .flatMap(([, rows]) => rows.map((m) => m.name)) + .filter((name): name is string => typeof name === "string" && name.length > 0), + ), + ]; + return values.length === 1 ? values[0] : ""; +} + +function CatalogSource({ entry }: { entry: RecordValue }) { + const source = record(entry.catalog_source); + if (source.kind === "direct") return

目录来源:当前产品直接声明。

; + if (source.kind !== "shared") return null; + const profiles = Array.isArray(source.profiles) + ? source.profiles.filter( + (profile): profile is string => + typeof profile === "string" && ["intl-cli", "intl-work"].includes(profile), + ) + : []; + const originals = Array.isArray(entry.source_variants) ? entry.source_variants.map(record) : []; + return ( + +

+ 继承自{profiles.length ? profiles.map(profileLabel).join("、") : "未注明来源"} + ;当前账号未直接声明此模型,权限与实际扣费以上游为准。 +

+
+ {originals.map((original, index) => ( +
+ + {profileLabel(typeof original.profile === "string" ? original.profile : "")} · + 来源版本 {index + 1} + + +
+ ))} +
+
+ ); +} + +export function ModelMetadata({ model }: { model: Facts }) { + const [selected, setSelected] = useState(""); + const sources = variants(model.metadata_by_profile); + const active = sources.find(([profile]) => profile === selected) ?? sources[0]; + const limits = record(model.limits); + return ( + <> +

+ 以下为上游声明,不代表模型原生模态或实测保证;目录参数不会自动覆盖客户端请求。 +

+ +
+ +
+ {["maxInputTokens", "maxOutputTokens"].map((key) => { + const limit = record(limits[key]); + return ( + +
{labels[key]}
+
+ {limit.state === "mixed" ? ( + "因路由而异" + ) : limit.state === "known" ? ( + + ) : ( + "未知" + )} +
+
+ ); + })} +
+
+
+ {!active ? ( + 未知不等于不支持;请等待账号目录同步。 + ) : ( + <> + + {active[1].length > 1 && ( +

同一产品的账号声明存在差异;以下保留各版本,不公开账号身份。

+ )} + {active[1].map((entry, index) => ( +
+ {active[1].length > 1 &&

声明 {index + 1}

} + {!Object.keys(entry).length && } + + {groups.map(([title, keys]) => { + const data = Object.fromEntries( + keys.filter((key) => Object.hasOwn(entry, key)).map((key) => [key, entry[key]]), + ); + return ( + Object.keys(data).length > 0 && ( + +
+ +
+
+ ) + ); + })} +
+ 查看安全元数据 JSON +
{JSON.stringify(entry, null, 2)}
+
+
+ ))} + + )} + + ); +} diff --git a/web/src/pages/Models.tsx b/web/src/pages/Models.tsx index cb120a7..678c93f 100644 --- a/web/src/pages/Models.tsx +++ b/web/src/pages/Models.tsx @@ -24,6 +24,7 @@ import { ResourceState, profileLabel, } from "../components"; +import { CapabilityBadges, ModelMetadata, modelDisplayName } from "../modelMetadata"; import s from "../ui.module.scss"; export const profiles = ["cn-cli", "cn-work", "intl-cli", "intl-work"]; const validId = (id: string) => /^[A-Za-z0-9_.:/@-]{1,160}$/.test(id) && ![".", ".."].includes(id); @@ -52,7 +53,10 @@ function bindingMode(rule: ModelRule): Mode { if (rule.credential_ids.length) return rule.region || rule.profile ? "legacy" : "accounts"; return rule.region || rule.profile ? "region" : "auto"; } -type Preview = { candidates: RecordValue[]; excluded: RecordValue[] }; +type Preview = { candidates: RecordValue[]; excluded: RecordValue[] } & Pick< + ModelRule, + "capabilities" | "limits" | "metadata_by_profile" +>; function RoutePreview({ preview }: { preview: Preview }) { return ( { @@ -376,7 +386,12 @@ export function ModelEditor({ - {preview && } + {preview && ( + <> + + + + )} ); } @@ -394,13 +409,17 @@ export function Models() { const resource = useResource("/models", modelResponse); const credentials = useResource("/credentials", credentialResponse); const [query, setQuery] = useState(""); + const [detailsId, setDetailsId] = useState(null); + const detailed = resource.data?.models.find((model) => model.id === detailsId); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); const [deleting, setDeleting] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const rows = resource.data?.models.filter((m) => - `${m.upstream_id ?? m.id} ${m.public_id}`.toLowerCase().includes(query.toLowerCase()), + `${m.upstream_id ?? m.id} ${m.public_id} ${modelDisplayName(m)}` + .toLowerCase() + .includes(query.toLowerCase()), ); return ( <> @@ -438,10 +457,18 @@ export function Models() {
setQuery(e.target.value)} /> + + 能力预检: + {resource.data?.model_capability_guard === undefined + ? "未知" + : resource.data.model_capability_guard + ? "开启" + : "关闭"} +
{rows && (rows.length ? ( @@ -452,6 +479,7 @@ export function Models() { 对外模型 / 上游 ID 状态 目录倍率 + 能力(上游声明) 路由边界 操作 @@ -462,6 +490,7 @@ export function Models() { {m.public_id} {m.upstream_id ?? m.id} + {modelDisplayName(m) && {modelDisplayName(m)}} {m.keep_original && 同时保留 {m.id}} @@ -491,6 +520,9 @@ export function Models() { 产品倍率未知 )} + + + {m.credential_ids.length ? `${m.credential_ids.length} 个指定账号` @@ -508,6 +540,7 @@ export function Models() {
+ {m.custom && (