Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
224 lines
8.0 KiB
Python
224 lines
8.0 KiB
Python
"""
|
||
统一的 LLM 客户端配置。
|
||
|
||
默认使用 OpenAI(读取 OPENAI_API_KEY,模型 gpt-5.6-luna)。
|
||
也支持通过环境变量 LLM_PROVIDER 切换到 Moonshot / 火山方舟(ARK),
|
||
它们都兼容 OpenAI 的 Chat Completions + 工具调用接口。
|
||
|
||
export LLM_PROVIDER=openai # 默认
|
||
export LLM_PROVIDER=moonshot # 用 MOONSHOT_API_KEY
|
||
export LLM_PROVIDER=ark # 用 ARK_API_KEY,并需设置 ARK_MODEL
|
||
|
||
统一的 OpenRouter 兜底(fallback):
|
||
若所选 provider 自己的 Key 缺失,但设置了 OPENROUTER_API_KEY,则自动改走
|
||
OpenRouter(https://openrouter.ai/api/v1),并把模型名映射到 OpenRouter 命名:
|
||
gpt-* -> openai/gpt-*
|
||
claude-* -> anthropic/claude-opus-4.8
|
||
含 "/" -> 原样透传
|
||
其它 -> openai/gpt-5.6-luna
|
||
"""
|
||
|
||
import os
|
||
import time
|
||
from typing import Any
|
||
from openai import OpenAI
|
||
from dotenv import load_dotenv
|
||
|
||
load_dotenv()
|
||
|
||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||
|
||
# 各提供商的默认配置:base_url / 环境变量名 / 默认模型
|
||
_PROVIDERS = {
|
||
"openai": {
|
||
"base_url": None, # 使用 SDK 默认
|
||
"key_env": "OPENAI_API_KEY",
|
||
"default_model": "gpt-5.6-luna",
|
||
},
|
||
"moonshot": {
|
||
"base_url": "https://api.moonshot.cn/v1",
|
||
"key_env": "MOONSHOT_API_KEY",
|
||
"default_model": "kimi-k3",
|
||
},
|
||
"ark": {
|
||
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||
"key_env": "ARK_API_KEY",
|
||
# ARK 需要用推理接入点(endpoint id) 作为 model,请通过 ARK_MODEL 指定
|
||
"default_model": os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"),
|
||
},
|
||
"openrouter": {
|
||
"base_url": OPENROUTER_BASE_URL,
|
||
"key_env": "OPENROUTER_API_KEY",
|
||
"default_model": "openai/gpt-4o-mini",
|
||
},
|
||
}
|
||
|
||
API_TURNS = []
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if hasattr(value, "model_dump"):
|
||
return _jsonable(value.model_dump(mode="json", exclude_none=True))
|
||
if isinstance(value, dict):
|
||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||
if isinstance(value, (list, tuple)):
|
||
return [_jsonable(item) for item in value]
|
||
return value
|
||
|
||
|
||
def get_provider() -> str:
|
||
return os.getenv("LLM_PROVIDER", "openai").lower().strip()
|
||
|
||
|
||
def _to_openrouter_model(model: str) -> str:
|
||
"""把常见模型名映射到 OpenRouter 命名空间。"""
|
||
if not model:
|
||
return "openai/gpt-5.6-luna"
|
||
if "/" in model:
|
||
return model
|
||
if model.startswith("gpt-"):
|
||
return "openai/" + model
|
||
if model.startswith("claude-"):
|
||
return "anthropic/claude-opus-4.8"
|
||
return "openai/gpt-5.6-luna"
|
||
|
||
|
||
def _is_reasoning_model(model: str) -> bool:
|
||
"""gpt-5.x / o1·o3·o4 / kimi-k3 / *reasoner 等推理模型:不接受 temperature=0,
|
||
直连 gpt-5.x 还需组织实名且工具调用受限,故优先走 OpenRouter。"""
|
||
m = (model or "").lower()
|
||
return (m.startswith(("gpt-5", "o1", "o3", "o4"))
|
||
or m.startswith("kimi-k3")
|
||
or "reasoner" in m or "thinking" in m)
|
||
|
||
|
||
def _use_openrouter(cfg: dict) -> bool:
|
||
"""走 OpenRouter 的两种情形:
|
||
1) provider 自己的 Key 缺失、但有 OPENROUTER_API_KEY(统一兜底);
|
||
2) 目标是 gpt-5.x 且有 OPENROUTER_API_KEY —— 直连 gpt-5.x 需组织实名、
|
||
且 /chat/completions 工具调用受限,故即便有 OPENAI_API_KEY 也优先 OpenRouter。"""
|
||
if not os.getenv("OPENROUTER_API_KEY"):
|
||
return False
|
||
if not os.getenv(cfg["key_env"]):
|
||
return True
|
||
model = os.getenv("LLM_MODEL") or cfg["default_model"]
|
||
return (model or "").lower().startswith("gpt-5")
|
||
|
||
|
||
def get_model() -> str:
|
||
"""允许用 LLM_MODEL 覆盖默认模型;OpenRouter 兜底路径下映射模型名。"""
|
||
provider = get_provider()
|
||
if provider not in _PROVIDERS:
|
||
raise ValueError(f"未知的 LLM_PROVIDER: {provider}")
|
||
cfg = _PROVIDERS[provider]
|
||
model = os.getenv("LLM_MODEL") or cfg["default_model"]
|
||
if _use_openrouter(cfg):
|
||
return _to_openrouter_model(model)
|
||
return model
|
||
|
||
|
||
def get_client() -> OpenAI:
|
||
provider = get_provider()
|
||
if provider not in _PROVIDERS:
|
||
raise ValueError(f"未知的 LLM_PROVIDER: {provider}")
|
||
cfg = _PROVIDERS[provider]
|
||
if _use_openrouter(cfg):
|
||
return OpenAI(api_key=os.getenv("OPENROUTER_API_KEY"), base_url=OPENROUTER_BASE_URL)
|
||
api_key = os.getenv(cfg["key_env"])
|
||
if not api_key:
|
||
raise RuntimeError(
|
||
f"环境变量 {cfg['key_env']} 未设置,也未设置 OPENROUTER_API_KEY。"
|
||
f"请参考 env.example 配置其一(OpenRouter 可作为统一兜底)后重试。"
|
||
)
|
||
kwargs = {"api_key": api_key}
|
||
if cfg["base_url"]:
|
||
kwargs["base_url"] = cfg["base_url"]
|
||
return OpenAI(**kwargs)
|
||
|
||
|
||
def record_completion(client: OpenAI, *, kind: str, **request: Any):
|
||
"""Execute and retain a credential-free raw request/response receipt."""
|
||
started = time.time()
|
||
response = client.chat.completions.create(**request)
|
||
API_TURNS.append({
|
||
"kind": kind,
|
||
"provider": get_provider(),
|
||
"endpoint": get_backend_metadata()["endpoint"],
|
||
"request": _jsonable(request),
|
||
"response": response.model_dump(mode="json", exclude_none=True),
|
||
"elapsed_seconds": round(time.time() - started, 6),
|
||
})
|
||
return response
|
||
|
||
|
||
def reset_api_turns() -> None:
|
||
API_TURNS.clear()
|
||
|
||
|
||
def get_api_turns() -> list[dict]:
|
||
return list(API_TURNS)
|
||
|
||
|
||
def get_backend_metadata() -> dict[str, Any]:
|
||
provider = get_provider()
|
||
cfg = _PROVIDERS[provider]
|
||
if _use_openrouter(cfg):
|
||
base_url = OPENROUTER_BASE_URL
|
||
key_env = "OPENROUTER_API_KEY"
|
||
routed_provider = "openrouter"
|
||
else:
|
||
base_url = cfg["base_url"] or "https://api.openai.com/v1"
|
||
key_env = cfg["key_env"]
|
||
routed_provider = provider
|
||
return {
|
||
"configured_provider": provider,
|
||
"routed_provider": routed_provider,
|
||
"model": get_model(),
|
||
"endpoint": f"{base_url}/chat/completions",
|
||
"credential_source_env": key_env,
|
||
"credential_value_recorded": False,
|
||
}
|
||
|
||
|
||
def usage_summary() -> dict[str, Any]:
|
||
prompt = completion = total = 0
|
||
native_cost = 0.0
|
||
native_cost_count = 0
|
||
for turn in API_TURNS:
|
||
usage = turn.get("response", {}).get("usage") or {}
|
||
prompt += int(usage.get("prompt_tokens") or 0)
|
||
completion += int(usage.get("completion_tokens") or 0)
|
||
total += int(usage.get("total_tokens") or 0)
|
||
if usage.get("cost") is not None:
|
||
native_cost += float(usage["cost"])
|
||
native_cost_count += 1
|
||
return {
|
||
"prompt_tokens": prompt,
|
||
"completion_tokens": completion,
|
||
"total_tokens": total or prompt + completion,
|
||
"provider_reported_cost_usd": round(native_cost, 9) if native_cost_count else None,
|
||
"provider_reported_cost_observations": native_cost_count,
|
||
"cost_qualification": (
|
||
"provider-native usage.cost summed across calls"
|
||
if native_cost_count else "provider did not expose monetary cost; no price was guessed"
|
||
),
|
||
}
|
||
|
||
|
||
# 全部 LLM 调用统一使用低温度,保证结果可复现;
|
||
# 但推理模型(gpt-5.x / o 系列 / kimi-k3 等)只接受默认 temperature=1,
|
||
# 故按当前解析出的模型自动选择默认温度(可用 LLM_TEMPERATURE 显式覆盖)。
|
||
def _default_temperature() -> str:
|
||
provider = get_provider()
|
||
cfg = _PROVIDERS.get(provider, _PROVIDERS["openai"])
|
||
model = os.getenv("LLM_MODEL") or cfg["default_model"]
|
||
return "1" if _is_reasoning_model(model) else "0"
|
||
|
||
|
||
def get_temperature() -> float:
|
||
"""在调用时按当前解析出的模型选择温度,使 CLI/env 的 --model/--provider
|
||
覆盖生效。原来的模块级 TEMPERATURE 常量在 import 时就被固定,而 demo.py 在
|
||
import 之后才设置 LLM_MODEL/LLM_PROVIDER,导致温度停留在默认模型的值
|
||
(例如把非推理模型误用 temperature=1,破坏了本文件追求的可复现性)。"""
|
||
return float(os.getenv("LLM_TEMPERATURE", _default_temperature()))
|