Files

257 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Any
# --------------------------------------------------------------------------
# 模型目录
#
# pricing 单位是「美元 / 百万 token」,用于 run 的成本估算。
# 新模型的价格会变,这份表只是开箱即用的默认值,用户可以在设置页改。
# --------------------------------------------------------------------------
ANTHROPIC_MODELS: list[dict[str, Any]] = [
{"id": "claude-opus-5", "label": "Claude Opus 5", "context": 1_000_000,
"pricing": {"input": 5.0, "output": 25.0}, "thinking": "adaptive"},
{"id": "claude-fable-5-1", "label": "Claude Fable 5.1", "context": 1_000_000,
"pricing": {"input": 10.0, "output": 50.0}, "thinking": "always"},
{"id": "claude-opus-4-8", "label": "Claude Opus 4.8", "context": 1_000_000,
"pricing": {"input": 5.0, "output": 25.0}, "thinking": "adaptive"},
{"id": "claude-sonnet-5", "label": "Claude Sonnet 5", "context": 1_000_000,
"pricing": {"input": 2.0, "output": 10.0}, "thinking": "adaptive"},
{"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5", "context": 200_000,
"pricing": {"input": 1.0, "output": 5.0}, "thinking": "budget"},
]
OPENAI_MODELS: list[dict[str, Any]] = [
{"id": "gpt-5.2", "label": "GPT-5.2", "context": 400_000,
"pricing": {"input": 1.25, "output": 10.0}},
{"id": "gpt-5.2-mini", "label": "GPT-5.2 mini", "context": 400_000,
"pricing": {"input": 0.25, "output": 2.0}},
{"id": "o4", "label": "o4", "context": 200_000,
"pricing": {"input": 3.0, "output": 12.0}},
]
# 常见的 OpenAI 兼容服务,一键添加用
COMPATIBLE_PRESETS: list[dict[str, Any]] = [
{
"key": "deepseek",
"label": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"models": [
{"id": "deepseek-chat", "label": "DeepSeek Chat", "context": 128_000,
"pricing": {"input": 0.27, "output": 1.1}},
{"id": "deepseek-reasoner", "label": "DeepSeek Reasoner", "context": 128_000,
"pricing": {"input": 0.55, "output": 2.19}},
],
},
{
"key": "moonshot",
"label": "Moonshot / Kimi",
"base_url": "https://api.moonshot.cn/v1",
"models": [
{"id": "kimi-k2-turbo-preview", "label": "Kimi K2 Turbo", "context": 256_000,
"pricing": {"input": 0.6, "output": 2.5}},
{"id": "moonshot-v1-128k", "label": "Moonshot v1 128k", "context": 128_000,
"pricing": {"input": 0.8, "output": 3.0}},
],
},
{
"key": "dashscope",
"label": "阿里云百炼 / 通义千问",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": [
{"id": "qwen-max", "label": "Qwen Max", "context": 32_000,
"pricing": {"input": 0.34, "output": 1.37}},
{"id": "qwen-plus", "label": "Qwen Plus", "context": 131_000,
"pricing": {"input": 0.11, "output": 0.28}},
],
},
{
"key": "zhipu",
"label": "智谱 GLM",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"models": [
{"id": "glm-4.6", "label": "GLM-4.6", "context": 200_000,
"pricing": {"input": 0.6, "output": 2.2}},
],
},
{
"key": "siliconflow",
"label": "硅基流动",
"base_url": "https://api.siliconflow.cn/v1",
"models": [
{"id": "deepseek-ai/DeepSeek-V3", "label": "DeepSeek V3", "context": 128_000,
"pricing": {"input": 0.27, "output": 1.1}},
],
},
{
"key": "openrouter",
"label": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"models": [
{"id": "anthropic/claude-opus-5", "label": "Claude Opus 5", "context": 1_000_000,
"pricing": {"input": 5.0, "output": 25.0}},
],
},
{
"key": "ollama",
"label": "Ollama(本地)",
"base_url": "http://localhost:11434/v1",
"api_key_optional": True,
"models": [
{"id": "qwen3:8b", "label": "Qwen3 8B(本地)", "context": 32_000,
"pricing": {"input": 0.0, "output": 0.0}},
{"id": "llama3.2", "label": "Llama 3.2(本地)", "context": 128_000,
"pricing": {"input": 0.0, "output": 0.0}},
],
},
]
MOCK_MODELS: list[dict[str, Any]] = [
{"id": "mock-fast", "label": "演示模型(无需 API Key)", "context": 128_000,
"pricing": {"input": 0.0, "output": 0.0}},
]
PROVIDER_KINDS = [
{
"kind": "anthropic",
"label": "Anthropic",
"base_url_hint": "留空使用官方地址;填写后使用自定义网关(默认读取 ANTHROPIC_BASE_URL)",
"models": ANTHROPIC_MODELS,
"default_model": "claude-opus-5",
},
{
"kind": "openai",
"label": "OpenAI",
"base_url_hint": "留空使用官方地址",
"models": OPENAI_MODELS,
"default_model": "gpt-5.2",
},
{
"kind": "openai_compatible",
"label": "OpenAI 兼容(DeepSeek / Kimi / 通义 / Ollama…)",
"base_url_hint": "必填,例如 https://api.deepseek.com/v1",
"models": [],
"presets": COMPATIBLE_PRESETS,
"default_model": "",
},
{
"kind": "mock",
"label": "演示模型(无需 API Key)",
"base_url_hint": "",
"models": MOCK_MODELS,
"default_model": "mock-fast",
},
]
# --------------------------------------------------------------------------
# 模型能力
# --------------------------------------------------------------------------
# Claude 4.6 及以后的模型移除了采样参数,传 temperature/top_p 会直接 400。
_NO_SAMPLING_PREFIXES = (
"claude-fable-5",
"claude-mythos-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-5",
"claude-sonnet-4-6",
)
# 支持 adaptive thinking(而不是老的 budget_tokens)的模型
_ADAPTIVE_THINKING_PREFIXES = _NO_SAMPLING_PREFIXES
# 支持 output_config.effort
_EFFORT_PREFIXES = _NO_SAMPLING_PREFIXES + ("claude-opus-4-5",)
# 交错思考(工具结果回来之后再想一段,而不是一轮开头想完就只剩执行)。
#
# 4.6 及以后走 adaptive thinking,交错是自带的,不需要也不该再挂 beta header
# ——在 Opus 4.6 上手动模式压根没有交错思考,只有 adaptive 有。上面那张
# _ADAPTIVE_THINKING_PREFIXES 覆盖的都属于这一档。
#
# 需要显式开的是 Claude 4 / 4.5 这一代。目录里没有列它们,但模型名是放行的
# (用户可以手填),所以这条路径要留着。Haiku 4.5 不支持,前缀也就不包含它。
_INTERLEAVED_BETA_PREFIXES = ("claude-opus-4", "claude-sonnet-4")
INTERLEAVED_BETA = "interleaved-thinking-2025-05-14"
def needs_interleaved_beta(model: str) -> bool:
"""这个模型要不要靠 beta header 才会在工具之间思考。"""
name = _norm(model)
if supports_adaptive_thinking(name):
return False # adaptive 自带,多挂一个 header 只会是噪音
return name.startswith(_INTERLEAVED_BETA_PREFIXES)
def _norm(model: str) -> str:
"""去掉 openrouter 之类的前缀,只看模型名本身。"""
return (model or "").split("/")[-1].strip()
def supports_sampling(model: str) -> bool:
"""这个模型还能不能传 temperature / top_p。"""
return not _norm(model).startswith(_NO_SAMPLING_PREFIXES)
def supports_adaptive_thinking(model: str) -> bool:
return _norm(model).startswith(_ADAPTIVE_THINKING_PREFIXES)
def supports_effort(model: str) -> bool:
return _norm(model).startswith(_EFFORT_PREFIXES)
def find_pricing(model: str) -> dict[str, float] | None:
target = _norm(model)
pools = [ANTHROPIC_MODELS, OPENAI_MODELS, MOCK_MODELS]
pools += [p["models"] for p in COMPATIBLE_PRESETS]
for pool in pools:
for entry in pool:
if _norm(entry["id"]) == target:
return entry.get("pricing")
return None
def find_context(model: str) -> int | None:
"""目录里登记的上下文窗口(token)。没登记的返回 None,由调用方取保守值。"""
target = _norm(model)
pools = [ANTHROPIC_MODELS, OPENAI_MODELS, MOCK_MODELS]
pools += [p["models"] for p in COMPATIBLE_PRESETS]
for pool in pools:
for entry in pool:
if _norm(entry["id"]) == target:
value = entry.get("context")
return int(value) if isinstance(value, (int, float)) and value > 0 else None
return None
def unpriced_call(data: Any) -> bool:
"""一次模型调用(llm.end 的数据)的金额估不出来:用了 token,模型却不在价格目录里,estimate_cost 只能给 0。
这时写 $0 像是没花钱(真实运行里上百次调用、上百万 token,运行页写着 $0)。本地模型在目录里标的是 0 价,
不算估不出来。事件自己标了 unpriced 的以它为准。"""
if not isinstance(data, dict):
return False
if isinstance(data.get("unpriced"), bool):
return data["unpriced"]
tokens = sum(v for v in (data.get("input_tokens"), data.get("output_tokens"))
if isinstance(v, (int, float)) and not isinstance(v, bool))
model = data.get("model")
return tokens > 0 and isinstance(model, str) and bool(model) and not data.get("cost_usd") \
and find_pricing(model) is None
def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
"""按目录价估算一次调用的花费(美元)。"""
pricing = find_pricing(model)
if not pricing:
return 0.0
return (
input_tokens / 1_000_000 * pricing.get("input", 0.0)
+ output_tokens / 1_000_000 * pricing.get("output", 0.0)
)