feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,11 @@ version = "0.0.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"openai>=1.40",
|
||||
"anthropic>=0.34",
|
||||
"google-genai>=0.3",
|
||||
"instructor>=1.5",
|
||||
"pydantic>=2.7",
|
||||
"tenacity>=8.2",
|
||||
"ww-shared",
|
||||
"ww-config",
|
||||
"ww-db",
|
||||
|
||||
119
packages/llm_gateway/tests/fakes_resilience.py
Normal file
119
packages/llm_gateway/tests/fakes_resilience.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""T5.4 韧性测试替身(多 provider 适配器 + 失败模拟 + 记账嗅探)——绝不联网。
|
||||
|
||||
放独立模块(非 conftest),测试走绝对导入 `from fakes_resilience import ...`。
|
||||
本目录无 __init__.py(见 fakes.py 注释)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.errors import TransientProviderError
|
||||
from ww_llm_gateway.routing import Route
|
||||
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
|
||||
|
||||
|
||||
class ScriptedAdapter:
|
||||
"""可编排成功/失败序列的假适配器。
|
||||
|
||||
`failures` 为开头要抛的异常列表(每次 complete/stream 消费一个);耗尽后正常返回。
|
||||
`capabilities_` 控制能力矩阵(测降级)。记录调用次数以断言回退/重试行为。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
*,
|
||||
text: str = "ok",
|
||||
failures: list[Exception] | None = None,
|
||||
capabilities_: Capabilities | None = None,
|
||||
input_tokens: int = 100,
|
||||
output_tokens: int = 50,
|
||||
structured_text: str | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.text = text
|
||||
self._failures = list(failures or [])
|
||||
self._caps = capabilities_ or Capabilities(structured_output=True, prefix_cache=True)
|
||||
self.input_tokens = input_tokens
|
||||
self.output_tokens = output_tokens
|
||||
self.structured_text = structured_text
|
||||
self.complete_calls = 0
|
||||
self.stream_calls = 0
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return self._caps
|
||||
|
||||
def _maybe_fail(self) -> None:
|
||||
if self._failures:
|
||||
raise self._failures.pop(0)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
self.complete_calls += 1
|
||||
self._maybe_fail()
|
||||
if req.output_schema is not None:
|
||||
# 模拟原生结构化输出:仅当能力声明支持时才会被网关派到这里。
|
||||
parsed = req.output_schema.model_validate({} if not _has_fields(req) else _stub(req))
|
||||
return ProviderResult(
|
||||
text=parsed.model_dump_json(),
|
||||
usage=ProviderUsage(
|
||||
input_tokens=self.input_tokens, output_tokens=self.output_tokens
|
||||
),
|
||||
parsed=parsed,
|
||||
)
|
||||
return ProviderResult(
|
||||
text=self.structured_text or self.text,
|
||||
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens),
|
||||
)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
self.stream_calls += 1
|
||||
self._maybe_fail()
|
||||
for ch in self.text:
|
||||
yield StreamChunk(text=ch)
|
||||
yield StreamChunk(
|
||||
usage=ProviderUsage(input_tokens=self.input_tokens, output_tokens=self.output_tokens)
|
||||
)
|
||||
|
||||
|
||||
def _has_fields(req: LlmRequest) -> bool:
|
||||
return bool(req.output_schema and req.output_schema.model_fields)
|
||||
|
||||
|
||||
def _stub(req: LlmRequest) -> dict[str, object]:
|
||||
# 用各字段默认/最小填充——测试 schema 仅需可构造。
|
||||
assert req.output_schema is not None
|
||||
out: dict[str, object] = {}
|
||||
for name, field in req.output_schema.model_fields.items():
|
||||
if field.is_required():
|
||||
out[name] = "x"
|
||||
return out
|
||||
|
||||
|
||||
class FakeLedger:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[Usage] = []
|
||||
|
||||
async def record(self, scope: Scope, usage: Usage) -> None:
|
||||
self.records.append(usage)
|
||||
|
||||
|
||||
def transient(msg: str = "boom") -> TransientProviderError:
|
||||
return TransientProviderError(msg)
|
||||
|
||||
|
||||
def chain(*routes: tuple[str, str]) -> list[Route]:
|
||||
return [Route(provider=p, model=m) for p, m in routes]
|
||||
|
||||
|
||||
def chain_resolver(routes: list[Route]) -> Callable[[Tier], list[Route]]:
|
||||
def _resolve(tier: Tier) -> list[Route]:
|
||||
return routes
|
||||
|
||||
return _resolve
|
||||
59
packages/llm_gateway/tests/test_build_adapter_factory.py
Normal file
59
packages/llm_gateway/tests/test_build_adapter_factory.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""`build_adapter` provider→适配器分派(C1扩,T5.4 follow-up #2)。
|
||||
|
||||
只断「按 provider 名选对适配器类 + provider 字段透传」,不联网(客户端构造无网络 IO,
|
||||
api_key 仅持有不校验)。Anthropic/Gemini 分支懒 import 真 SDK(已 `uv sync`)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
|
||||
from ww_llm_gateway.adapters.gemini import GeminiAdapter
|
||||
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
|
||||
|
||||
_FAKE_KEY = "sk-test-not-a-real-key"
|
||||
|
||||
|
||||
def test_dispatches_anthropic_provider_to_anthropic_adapter() -> None:
|
||||
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, AnthropicAdapter)
|
||||
assert adapter.provider == "anthropic"
|
||||
|
||||
|
||||
def test_dispatches_gemini_provider_to_gemini_adapter() -> None:
|
||||
adapter = build_adapter("gemini", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, GeminiAdapter)
|
||||
assert adapter.provider == "gemini"
|
||||
|
||||
|
||||
def test_dispatches_google_alias_to_gemini_adapter() -> None:
|
||||
adapter = build_adapter("google", api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, GeminiAdapter)
|
||||
assert adapter.provider == "google"
|
||||
|
||||
|
||||
def test_dispatches_deepseek_to_openai_compat_adapter() -> None:
|
||||
adapter = build_adapter("deepseek", api_key=_FAKE_KEY, base_url="https://api.deepseek.com")
|
||||
|
||||
assert isinstance(adapter, OpenAICompatAdapter)
|
||||
assert adapter.provider == "deepseek"
|
||||
|
||||
|
||||
def test_dispatches_unknown_provider_to_openai_compat_adapter() -> None:
|
||||
# kimi/qwen/glm/openai 等一律走 OpenAI 兼容默认分支。
|
||||
for provider in ("openai", "kimi", "qwen", "glm"):
|
||||
adapter = build_adapter(provider, api_key=_FAKE_KEY)
|
||||
|
||||
assert isinstance(adapter, OpenAICompatAdapter)
|
||||
assert adapter.provider == provider
|
||||
|
||||
|
||||
def test_anthropic_adapter_capabilities_reflect_native_structured_output() -> None:
|
||||
adapter = build_adapter("anthropic", api_key=_FAKE_KEY)
|
||||
caps = adapter.capabilities()
|
||||
|
||||
assert caps.structured_output is True
|
||||
assert caps.prefix_cache is True
|
||||
109
packages/llm_gateway/tests/test_capability_degradation.py
Normal file
109
packages/llm_gateway/tests/test_capability_degradation.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""T5.4 能力协商 / 降级(ARCH §4.4)。
|
||||
|
||||
结构化输出:原生不支持时降级(instructor JSON-提示路径仍由适配器自处理;网关
|
||||
层负责在链内**优先选支持结构化输出的 provider**,无则降级到首个可用、对上层透明)。
|
||||
降级时 `served_by.degraded=True` 标注,记账/日志可见,正确性不受影响。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.base import Capabilities
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
class Tiny(BaseModel):
|
||||
x: str
|
||||
|
||||
|
||||
def _structured_req() -> LlmRequest:
|
||||
return LlmRequest(
|
||||
tier="analyst",
|
||||
input="给我结构化",
|
||||
output_schema=Tiny,
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
|
||||
async def test_prefers_structured_capable_provider_in_chain() -> None:
|
||||
# 主 provider 不支持结构化输出,回退 provider 支持 → 网关优先选支持者服务结构化请求。
|
||||
no_struct = ScriptedAdapter(
|
||||
"weakprov",
|
||||
capabilities_=Capabilities(structured_output=False, prefix_cache=False),
|
||||
)
|
||||
struct = ScriptedAdapter(
|
||||
"strongprov",
|
||||
capabilities_=Capabilities(structured_output=True, prefix_cache=True),
|
||||
)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"weakprov": no_struct, "strongprov": struct},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"), ("strongprov", "s"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(_structured_req())
|
||||
|
||||
assert resp.served_by.provider == "strongprov"
|
||||
assert resp.parsed is not None
|
||||
assert no_struct.complete_calls == 0
|
||||
|
||||
|
||||
async def test_degrades_when_no_structured_capable_provider() -> None:
|
||||
# 链上无 provider 支持结构化输出 → 降级用首个可用(适配器自走 instructor JSON 提示)
|
||||
# 并标 served_by.degraded=True,不硬失败。
|
||||
weak = ScriptedAdapter(
|
||||
"weakprov",
|
||||
capabilities_=Capabilities(structured_output=False),
|
||||
)
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(_structured_req())
|
||||
|
||||
assert resp.served_by.provider == "weakprov"
|
||||
assert resp.served_by.degraded is True
|
||||
assert weak.complete_calls == 1
|
||||
|
||||
|
||||
async def test_no_degradation_flag_for_plain_text() -> None:
|
||||
# 纯文本请求对任何 provider 都不算降级。
|
||||
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(structured_output=False))
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
req = LlmRequest(tier="writer", input="正文", scope=Scope(user_id=uuid.UUID(int=1)))
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.degraded is False
|
||||
|
||||
|
||||
async def test_cache_blocks_passed_through_regardless_of_capability() -> None:
|
||||
# 前缀缓存不支持时只是跳过,不改正确性、不算降级。
|
||||
weak = ScriptedAdapter("weakprov", capabilities_=Capabilities(prefix_cache=False))
|
||||
gw = Gateway(
|
||||
{"weakprov": weak},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("weakprov", "w"))),
|
||||
)
|
||||
req = LlmRequest(
|
||||
tier="writer",
|
||||
input="正文",
|
||||
system=[Block(text="世界观硬规则", cache=True)],
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text
|
||||
assert resp.served_by.degraded is False
|
||||
174
packages/llm_gateway/tests/test_fallback_chain.py
Normal file
174
packages/llm_gateway/tests/test_fallback_chain.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""T5.4 回退链 + 重试 + 熔断(ARCH §4.5)。
|
||||
|
||||
主模型 transient 失败 → 退避重试 → 仍失败切回退链下一个;回退服务时标
|
||||
`served_by.fell_back=True`;记账落实际服务方;链耗尽抛 LLM_UNAVAILABLE。
|
||||
熔断:某 provider 连续失败超阈值后短时熔断、直接跳过走回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
|
||||
from ww_llm_gateway.gateway import CircuitBreaker, Gateway
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
async def test_primary_success_no_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", text="主模型")
|
||||
backup = ScriptedAdapter("openai", text="备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "主模型"
|
||||
assert resp.served_by.provider == "deepseek"
|
||||
assert resp.served_by.fell_back is False
|
||||
assert backup.complete_calls == 0
|
||||
assert ledger.records[0].provider == "deepseek"
|
||||
|
||||
|
||||
async def test_falls_through_to_next_provider_on_transient(req: LlmRequest) -> None:
|
||||
# 主模型每次 complete 都 transient 失败(足够耗尽重试) → 切回退。
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="备用结果")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=2,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "备用结果"
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
# 记账记实际服务方 openai,不是失败的 deepseek。
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].provider == "openai"
|
||||
|
||||
|
||||
async def test_retries_then_succeeds_on_same_provider(req: LlmRequest) -> None:
|
||||
# 前两次 transient,第三次成功 → 不应切回退(max_retries=2 即最多 3 次尝试)。
|
||||
primary = ScriptedAdapter("deepseek", text="重试后成功", failures=[transient(), transient()])
|
||||
backup = ScriptedAdapter("openai", text="备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=2,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.text == "重试后成功"
|
||||
assert resp.served_by.provider == "deepseek"
|
||||
assert resp.served_by.fell_back is False
|
||||
assert backup.complete_calls == 0
|
||||
|
||||
|
||||
async def test_rate_limited_triggers_fallback(req: LlmRequest) -> None:
|
||||
rate_limited = AppError(ErrorCode.RATE_LIMITED, "429")
|
||||
primary = ScriptedAdapter("deepseek", failures=[rate_limited for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="降级备用")
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
|
||||
|
||||
async def test_chain_exhausted_raises_llm_unavailable(req: LlmRequest) -> None:
|
||||
p1 = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
p2 = ScriptedAdapter("openai", failures=[transient() for _ in range(10)])
|
||||
gw = Gateway(
|
||||
{"deepseek": p1, "openai": p2},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
with pytest.raises(AppError) as exc:
|
||||
await gw.run(req)
|
||||
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_missing_adapter_in_chain_skipped(req: LlmRequest) -> None:
|
||||
# 链上首个 provider 没注册适配器 → 跳过、走下一个(不硬失败)。
|
||||
backup = ScriptedAdapter("openai", text="可用")
|
||||
gw = Gateway(
|
||||
{"openai": backup},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("missing", "m"), ("openai", "gpt-4o"))),
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
|
||||
|
||||
# ---- 熔断器 ----
|
||||
|
||||
|
||||
def test_circuit_breaker_trips_after_threshold() -> None:
|
||||
cb = CircuitBreaker(threshold=3, reset_seconds=60.0)
|
||||
assert cb.is_open("deepseek") is False
|
||||
cb.record_failure("deepseek")
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is False # 未到阈值
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is True # 第 3 次 → 熔断
|
||||
|
||||
|
||||
def test_circuit_breaker_success_resets() -> None:
|
||||
cb = CircuitBreaker(threshold=2, reset_seconds=60.0)
|
||||
cb.record_failure("deepseek")
|
||||
cb.record_success("deepseek")
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is False # 成功清零计数
|
||||
|
||||
|
||||
def test_circuit_breaker_reopens_after_cooldown() -> None:
|
||||
now = [1000.0]
|
||||
cb = CircuitBreaker(threshold=1, reset_seconds=30.0, clock=lambda: now[0])
|
||||
cb.record_failure("deepseek")
|
||||
assert cb.is_open("deepseek") is True
|
||||
now[0] += 31.0 # 冷却窗口过 → 半开(放行试探)
|
||||
assert cb.is_open("deepseek") is False
|
||||
|
||||
|
||||
async def test_open_circuit_skips_provider(req: LlmRequest) -> None:
|
||||
# 熔断已打开的主 provider 被直接跳过,连 complete 都不调,直接走回退。
|
||||
primary = ScriptedAdapter("deepseek", text="不该被调")
|
||||
backup = ScriptedAdapter("openai", text="回退服务")
|
||||
cb = CircuitBreaker(threshold=1, reset_seconds=60.0)
|
||||
cb.record_failure("deepseek") # 预先熔断
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
FakeLedger(),
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
breaker=cb,
|
||||
)
|
||||
|
||||
resp = await gw.run(req)
|
||||
|
||||
assert resp.served_by.provider == "openai"
|
||||
assert resp.served_by.fell_back is True
|
||||
assert primary.complete_calls == 0
|
||||
139
packages/llm_gateway/tests/test_kimi_code_adapter.py
Normal file
139
packages/llm_gateway/tests/test_kimi_code_adapter.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Kimi Code 适配器单测(K1.2):断完整 7 头伪造头集 + base_url + bearer(不联网)。
|
||||
|
||||
验证点:构造出的 `AsyncOpenAI` 客户端带正确 base_url + default_headers(opencode 规范的
|
||||
完整 7 头:UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`,真源 `ooojustin/opencode-kimi`),
|
||||
access_token 作为 bearer,且适配器 provider 字段为 `kimi-code`。device-id 经环境变量
|
||||
`KIMI_DEVICE_ID` 注入以保证 CI 确定性(不触碰真实文件/HOME)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from ww_llm_gateway.adapters.kimi_code import (
|
||||
KIMI_CLI_VERSION,
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_PLATFORM,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
build_kimi_code_client,
|
||||
kimi_code_headers,
|
||||
kimi_device_id,
|
||||
)
|
||||
|
||||
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
|
||||
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
#: opencode 发送的全部 7 个头键。
|
||||
_EXPECTED_HEADER_KEYS = {
|
||||
"User-Agent",
|
||||
"X-Msh-Platform",
|
||||
"X-Msh-Version",
|
||||
"X-Msh-Device-Name",
|
||||
"X-Msh-Device-Model",
|
||||
"X-Msh-Device-Id",
|
||||
"X-Msh-Os-Version",
|
||||
}
|
||||
|
||||
_HEX32_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
_ASCII_RE = re.compile(r"^[\x20-\x7e]+$")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""注入固定 device-id,使测试确定且不依赖真实 ~/.kimi 文件。"""
|
||||
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
|
||||
|
||||
|
||||
def test_user_agent_matches_kimi_cli_pattern() -> None:
|
||||
assert KIMI_CODE_USER_AGENT == "KimiCLI/1.37.0"
|
||||
assert KIMI_CODE_USER_AGENT == f"KimiCLI/{KIMI_CLI_VERSION}"
|
||||
assert KIMI_CODE_USER_AGENT.startswith("KimiCLI/")
|
||||
|
||||
|
||||
def test_headers_contain_all_seven_keys() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
assert set(headers) == _EXPECTED_HEADER_KEYS
|
||||
|
||||
|
||||
def test_headers_fixed_literal_values() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
assert headers["User-Agent"] == "KimiCLI/1.37.0"
|
||||
assert headers["X-Msh-Platform"] == KIMI_CODE_PLATFORM == "kimi_cli"
|
||||
assert headers["X-Msh-Version"] == "1.37.0"
|
||||
# UA 版本必须与 X-Msh-Version 一致。
|
||||
assert headers["X-Msh-Version"] == headers["User-Agent"].removeprefix("KimiCLI/")
|
||||
|
||||
|
||||
def test_device_id_is_stable_32_char_lowercase_hex() -> None:
|
||||
headers_a = kimi_code_headers()
|
||||
headers_b = kimi_code_headers()
|
||||
|
||||
device_id = headers_a["X-Msh-Device-Id"]
|
||||
assert _HEX32_RE.match(device_id) # 32 位小写 hex,无连字符
|
||||
assert "-" not in device_id
|
||||
# 跨两次调用稳定。
|
||||
assert headers_a["X-Msh-Device-Id"] == headers_b["X-Msh-Device-Id"]
|
||||
assert device_id == _TEST_DEVICE_ID
|
||||
|
||||
|
||||
def test_kimi_device_id_helper_stable_and_matches_env() -> None:
|
||||
assert kimi_device_id() == _TEST_DEVICE_ID
|
||||
assert kimi_device_id() == kimi_device_id()
|
||||
|
||||
|
||||
def test_host_derived_headers_present_nonempty_ascii() -> None:
|
||||
headers = kimi_code_headers()
|
||||
|
||||
for key in ("X-Msh-Device-Name", "X-Msh-Device-Model", "X-Msh-Os-Version"):
|
||||
value = headers[key]
|
||||
assert value, f"{key} must be non-empty"
|
||||
assert _ASCII_RE.match(value), f"{key} must be printable ASCII"
|
||||
|
||||
|
||||
def test_build_client_sets_base_url_and_full_headers() -> None:
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN)
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
|
||||
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
|
||||
|
||||
|
||||
def test_build_client_uses_access_token_as_bearer() -> None:
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN)
|
||||
|
||||
# access token 作为 OpenAI client 的 api_key → SDK 自动发 Authorization: Bearer。
|
||||
assert client.api_key == _ACCESS_TOKEN
|
||||
|
||||
|
||||
def test_build_client_honors_explicit_base_url_override() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
client = build_kimi_code_client(_ACCESS_TOKEN, base_url=custom)
|
||||
|
||||
assert str(client.base_url).rstrip("/") == custom.rstrip("/")
|
||||
|
||||
|
||||
def test_adapter_provider_is_kimi_code() -> None:
|
||||
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
|
||||
|
||||
assert adapter.provider == "kimi-code"
|
||||
# 复用 OpenAI 兼容能力(结构化输出 + 前缀缓存)。
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
|
||||
|
||||
def test_structured_client_uses_instructor_json_mode() -> None:
|
||||
"""kimi-for-coding 开启 thinking,与强制 tool_choice 互斥(live 400)。
|
||||
|
||||
因此 KimiCodeAdapter 的结构化客户端必须走 instructor JSON 模式(发
|
||||
`response_format`,**不**发 `tool_choice`),而非默认的 TOOLS 模式。
|
||||
"""
|
||||
import instructor
|
||||
|
||||
adapter = KimiCodeAdapter(build_kimi_code_client(_ACCESS_TOKEN))
|
||||
|
||||
structured = adapter._structured()
|
||||
assert getattr(structured, "mode", None) is instructor.Mode.JSON
|
||||
62
packages/llm_gateway/tests/test_kimi_code_factory.py
Normal file
62
packages/llm_gateway/tests/test_kimi_code_factory.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""工厂分派 `kimi-code` 单测(K1.2):access_token 当 api_key、完整 7 伪造头、coding base。
|
||||
|
||||
不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.kimi_code import (
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
)
|
||||
|
||||
_ACCESS_TOKEN = "kimi-oauth-access-token-not-real"
|
||||
_TEST_DEVICE_ID = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
_EXPECTED_HEADER_KEYS = {
|
||||
"User-Agent",
|
||||
"X-Msh-Platform",
|
||||
"X-Msh-Version",
|
||||
"X-Msh-Device-Name",
|
||||
"X-Msh-Device-Model",
|
||||
"X-Msh-Device-Id",
|
||||
"X-Msh-Os-Version",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_device_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""注入固定 device-id,使测试确定且不依赖真实 ~/.kimi 文件。"""
|
||||
monkeypatch.setenv("KIMI_DEVICE_ID", _TEST_DEVICE_ID)
|
||||
|
||||
|
||||
def test_factory_dispatches_kimi_code_to_kimi_code_adapter() -> None:
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
assert adapter.provider == "kimi-code"
|
||||
|
||||
|
||||
def test_factory_kimi_code_defaults_base_url_and_attaches_full_headers() -> None:
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN)
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.api_key == _ACCESS_TOKEN
|
||||
assert client.default_headers["User-Agent"] == KIMI_CODE_USER_AGENT
|
||||
assert client.default_headers["X-Msh-Platform"] == "kimi_cli"
|
||||
assert client.default_headers["X-Msh-Version"] == "1.37.0"
|
||||
assert client.default_headers["X-Msh-Device-Id"] == _TEST_DEVICE_ID
|
||||
assert _EXPECTED_HEADER_KEYS.issubset(set(client.default_headers))
|
||||
|
||||
|
||||
def test_factory_kimi_code_honors_explicit_base_url() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
adapter = build_adapter("kimi-code", api_key=_ACCESS_TOKEN, base_url=custom)
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
|
||||
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001
|
||||
69
packages/llm_gateway/tests/test_kimi_code_key_factory.py
Normal file
69
packages/llm_gateway/tests/test_kimi_code_key_factory.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""工厂分派 `kimi-code-key` 单测:静态 Console Key 的 ToS 合规变体(不联网)。
|
||||
|
||||
`kimi-code-key` = Kimi Code 订阅 plan 的 **Console 静态 API Key** 路径——与 OAuth 的
|
||||
`kimi-code` 命中同一 coding 端点 + 同一 model `kimi-for-coding` + 同样 thinking 开启
|
||||
(故结构化输出走 JSON 模式)。**实测纠正**:coding 端点据 `User-Agent` 做 allow-list 门禁,
|
||||
缺伪造头 → `403 access_terminated_error`,**无论 key 还是 OAuth**。故静态 key 路径也必须发
|
||||
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头(区别仅凭据来源是静态 key)。两条订阅路径
|
||||
都需 UA 伪造 = 同样 ToS 风险;真正合规的只有 moonshot 平台 key(provider `kimi`)。
|
||||
|
||||
断言点:
|
||||
- 命中 coding base URL;key 作为 bearer(OpenAI client 的 `api_key`);
|
||||
- default_headers **含** 伪造 UA(`KimiCLI`)+ `X-Msh-*` 头(过 coding 端点门禁);
|
||||
- 结构化客户端走 instructor JSON 模式(与 thinking 兼容)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import instructor
|
||||
from ww_llm_gateway import build_adapter
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_BASE_URL
|
||||
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
|
||||
|
||||
_API_KEY = "kimi-console-static-key-not-real"
|
||||
|
||||
|
||||
def test_factory_dispatches_kimi_code_key_to_kimi_code_key_adapter() -> None:
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
assert adapter.provider == "kimi-code-key"
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_defaults_coding_base_url_and_bearer() -> None:
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
client = adapter._client # noqa: SLF001 — 断言注入客户端配置
|
||||
|
||||
assert str(client.base_url).rstrip("/") == KIMI_CODE_BASE_URL.rstrip("/")
|
||||
assert client.api_key == _API_KEY
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_sends_spoofed_headers() -> None:
|
||||
"""实测纠正:coding 端点据 UA 做 allow-list 门禁(缺伪造头→403),故静态 key 也必须发
|
||||
与 OAuth 相同的 `KimiCLI/1.37.0` + `X-Msh-*` 头。"""
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
headers = adapter._client.default_headers # noqa: SLF001
|
||||
|
||||
# 伪造官方客户端 UA(过 coding 端点 access_terminated_error 门禁)。
|
||||
assert "KimiCLI" in str(headers.get("User-Agent", ""))
|
||||
# 带 OAuth 路径同款 X-Msh-* 头集。
|
||||
assert any(str(k).lower().startswith("x-msh-") for k in headers)
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_structured_client_uses_json_mode() -> None:
|
||||
"""coding 端点 thinking 开启 → 结构化必须走 instructor JSON 模式(非 TOOLS)。"""
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
|
||||
structured = adapter._structured() # noqa: SLF001
|
||||
assert getattr(structured, "mode", None) is instructor.Mode.JSON
|
||||
|
||||
|
||||
def test_factory_kimi_code_key_honors_explicit_base_url() -> None:
|
||||
custom = "https://example.test/coding/v1"
|
||||
adapter = build_adapter(KIMI_CODE_KEY_PROVIDER, api_key=_API_KEY, base_url=custom)
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
|
||||
assert str(adapter._client.base_url).rstrip("/") == custom.rstrip("/") # noqa: SLF001
|
||||
51
packages/llm_gateway/tests/test_multi_provider_accounting.py
Normal file
51
packages/llm_gateway/tests/test_multi_provider_accounting.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""T5.4 多 provider 记账维度(ARCH §4.8)。
|
||||
|
||||
记账行须带**实际服务方** provider/model(回退后不能记成主模型),让 usage_ledger
|
||||
的多 provider 维度可查询/聚合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fakes_resilience import FakeLedger, ScriptedAdapter, chain, chain_resolver, transient
|
||||
from ww_llm_gateway.gateway import Gateway
|
||||
from ww_llm_gateway.types import LlmRequest
|
||||
|
||||
|
||||
async def test_ledger_records_actual_serving_provider_after_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="备用", input_tokens=222, output_tokens=33)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
await gw.run(req)
|
||||
|
||||
assert len(ledger.records) == 1
|
||||
rec = ledger.records[0]
|
||||
assert rec.provider == "openai"
|
||||
assert rec.model == "gpt-4o"
|
||||
assert rec.input_tokens == 222
|
||||
assert rec.output_tokens == 33
|
||||
|
||||
|
||||
async def test_stream_ledger_records_actual_provider_after_fallback(req: LlmRequest) -> None:
|
||||
primary = ScriptedAdapter("deepseek", failures=[transient() for _ in range(10)])
|
||||
backup = ScriptedAdapter("openai", text="流式备用", input_tokens=10, output_tokens=4)
|
||||
ledger = FakeLedger()
|
||||
gw = Gateway(
|
||||
{"deepseek": primary, "openai": backup},
|
||||
ledger,
|
||||
chain_resolver=chain_resolver(chain(("deepseek", "deepseek-chat"), ("openai", "gpt-4o"))),
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
collected = [d.text async for d in gw.stream(req)]
|
||||
|
||||
assert "".join(collected) == "流式备用"
|
||||
assert len(ledger.records) == 1
|
||||
assert ledger.records[0].provider == "openai"
|
||||
assert ledger.records[0].model == "gpt-4o"
|
||||
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
198
packages/llm_gateway/tests/test_new_adapters.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""T5.4 新适配器:Anthropic + Gemini(ARCH §4.2/§4.4)。
|
||||
|
||||
网络客户端经注入的 Protocol 替身——绝不联网、绝不依赖真实 SDK。
|
||||
断言:能力矩阵声明正确、complete/stream 归一化、结构化输出、usage 提取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.adapters.anthropic import AnthropicAdapter
|
||||
from ww_llm_gateway.adapters.gemini import GeminiAdapter
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
|
||||
|
||||
class Out(BaseModel):
|
||||
score: int
|
||||
|
||||
|
||||
# ---- Anthropic ----
|
||||
|
||||
|
||||
class _FakeAnthropicMessages:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int, cache_read: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self._cache_read = cache_read
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def create(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Block:
|
||||
type = "text"
|
||||
text = self._text
|
||||
|
||||
class _Usage:
|
||||
input_tokens = self._in
|
||||
output_tokens = self._out
|
||||
cache_read_input_tokens = self._cache_read
|
||||
|
||||
class _Resp:
|
||||
content = [_Block()]
|
||||
usage = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeAnthropicClient:
|
||||
def __init__(self, msgs: _FakeAnthropicMessages) -> None:
|
||||
self.messages = msgs
|
||||
|
||||
|
||||
def _req(text: str = "写第一章", *, cache: bool = False) -> LlmRequest:
|
||||
sys = [Block(text="世界观", cache=cache)] if cache else []
|
||||
return LlmRequest(tier="writer", input=text, system=sys, scope=Scope(user_id=uuid.UUID(int=1)))
|
||||
|
||||
|
||||
def test_anthropic_capabilities() -> None:
|
||||
client = _FakeAnthropicClient(_FakeAnthropicMessages("", 0, 0, 0))
|
||||
adapter = AnthropicAdapter("anthropic", client)
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.prefix_cache is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_anthropic_complete_text_and_usage() -> None:
|
||||
msgs = _FakeAnthropicMessages("生成正文", in_tok=120, out_tok=80, cache_read=30)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
result = await adapter.complete(_req(), "claude-3-5-sonnet")
|
||||
|
||||
assert result.text == "生成正文"
|
||||
assert result.usage.input_tokens == 120
|
||||
assert result.usage.output_tokens == 80
|
||||
assert result.usage.cache_read_tokens == 30
|
||||
|
||||
|
||||
async def test_anthropic_marks_cache_breakpoint() -> None:
|
||||
msgs = _FakeAnthropicMessages("x", 1, 1, 0)
|
||||
adapter = AnthropicAdapter("anthropic", _FakeAnthropicClient(msgs))
|
||||
|
||||
await adapter.complete(_req(cache=True), "claude-3-5-sonnet")
|
||||
|
||||
# 稳定块应带 cache_control 断点(Claude 显式缓存,§4.6)。
|
||||
system = msgs.last_kwargs["system"]
|
||||
assert any(blk.get("cache_control") for blk in system)
|
||||
|
||||
|
||||
# ---- Gemini ----
|
||||
|
||||
|
||||
class _FakeGeminiModels:
|
||||
def __init__(self, text: str, in_tok: int, out_tok: int) -> None:
|
||||
self._text = text
|
||||
self._in = in_tok
|
||||
self._out = out_tok
|
||||
self.last_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def generate_content(self, **kwargs: Any) -> Any:
|
||||
self.last_kwargs = kwargs
|
||||
|
||||
class _Usage:
|
||||
prompt_token_count = self._in
|
||||
candidates_token_count = self._out
|
||||
cached_content_token_count = 0
|
||||
|
||||
class _Resp:
|
||||
text = self._text
|
||||
usage_metadata = _Usage()
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
class _FakeGeminiAio:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.models = models
|
||||
|
||||
|
||||
class _FakeGeminiClient:
|
||||
def __init__(self, models: _FakeGeminiModels) -> None:
|
||||
self.aio = _FakeGeminiAio(models)
|
||||
|
||||
|
||||
def test_gemini_capabilities() -> None:
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(_FakeGeminiModels("", 0, 0))) # type: ignore[arg-type]
|
||||
caps = adapter.capabilities()
|
||||
assert caps.structured_output is True
|
||||
assert caps.thinking is True
|
||||
|
||||
|
||||
async def test_gemini_complete_text_and_usage() -> None:
|
||||
models = _FakeGeminiModels("双子座正文", in_tok=200, out_tok=90)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
|
||||
result = await adapter.complete(_req(), "gemini-2.0-flash")
|
||||
|
||||
assert result.text == "双子座正文"
|
||||
assert result.usage.input_tokens == 200
|
||||
assert result.usage.output_tokens == 90
|
||||
|
||||
|
||||
async def test_gemini_structured_output() -> None:
|
||||
models = _FakeGeminiModels('{"score": 7}', in_tok=10, out_tok=5)
|
||||
adapter = GeminiAdapter("gemini", _FakeGeminiClient(models)) # type: ignore[arg-type]
|
||||
req = LlmRequest(
|
||||
tier="analyst",
|
||||
input="打分",
|
||||
output_schema=Out,
|
||||
scope=Scope(user_id=uuid.UUID(int=1)),
|
||||
)
|
||||
|
||||
result = await adapter.complete(req, "gemini-2.0-flash")
|
||||
|
||||
assert result.parsed is not None
|
||||
assert isinstance(result.parsed, Out)
|
||||
assert result.parsed.score == 7
|
||||
# 结构化请求应带 response schema 配置。
|
||||
assert "config" in models.last_kwargs
|
||||
|
||||
|
||||
async def test_anthropic_stream_yields_text_then_usage() -> None:
|
||||
class _StreamEvent:
|
||||
def __init__(self, **kw: Any) -> None:
|
||||
self.__dict__.update(kw)
|
||||
|
||||
class _StreamMessages:
|
||||
def stream(self, **kwargs: Any) -> Any:
|
||||
class _Ctx:
|
||||
async def __aenter__(self_inner) -> AsyncIterator[Any]:
|
||||
async def _gen() -> AsyncIterator[Any]:
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="hel")
|
||||
)
|
||||
yield _StreamEvent(
|
||||
type="content_block_delta", delta=_StreamEvent(text="lo")
|
||||
)
|
||||
|
||||
return _gen()
|
||||
|
||||
async def __aexit__(self_inner, *a: Any) -> None:
|
||||
return None
|
||||
|
||||
return _Ctx()
|
||||
|
||||
class _Client:
|
||||
def __init__(self) -> None:
|
||||
self.messages = _StreamMessages()
|
||||
|
||||
adapter = AnthropicAdapter("anthropic", _Client())
|
||||
chunks = [c async for c in adapter.stream(_req(), "claude-3-5-sonnet")]
|
||||
text = "".join(c.text for c in chunks if c.text)
|
||||
assert text == "hello"
|
||||
@@ -1,7 +1,11 @@
|
||||
"""LLM 网关(C1 / ARCH §4):薄自建,tier→provider+model,屏蔽厂商差异。"""
|
||||
"""LLM 网关(C1 / ARCH §4):薄自建,tier→provider+model,屏蔽厂商差异。
|
||||
|
||||
T5.4 扩:多适配器(Anthropic/Gemini/OpenAI 兼容)+ 回退链 + 熔断 + 能力协商/降级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .adapters.anthropic import AnthropicAdapter
|
||||
from .adapters.base import (
|
||||
Capabilities,
|
||||
ProviderAdapter,
|
||||
@@ -9,10 +13,30 @@ from .adapters.base import (
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from .adapters.gemini import GeminiAdapter
|
||||
from .adapters.kimi_code import (
|
||||
KIMI_CLI_VERSION,
|
||||
KIMI_CODE_BASE_URL,
|
||||
KIMI_CODE_PLATFORM,
|
||||
KIMI_CODE_PROVIDER,
|
||||
KIMI_CODE_USER_AGENT,
|
||||
KimiCodeAdapter,
|
||||
build_kimi_code_client,
|
||||
kimi_code_headers,
|
||||
kimi_device_id,
|
||||
kimi_device_model,
|
||||
)
|
||||
from .adapters.kimi_code_key import (
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
KimiCodeKeyAdapter,
|
||||
build_kimi_code_key_client,
|
||||
)
|
||||
from .adapters.openai_compat import OpenAICompatAdapter
|
||||
from .gateway import Gateway
|
||||
from .errors import TransientProviderError
|
||||
from .factory import build_adapter
|
||||
from .gateway import CircuitBreaker, Gateway
|
||||
from .ledger import LedgerSink, SqlAlchemyLedgerSink
|
||||
from .routing import Route, resolve_route
|
||||
from .routing import ChainResolver, Route, chain_from_routing, resolve_chain, resolve_route
|
||||
from .types import (
|
||||
Block,
|
||||
Delta,
|
||||
@@ -25,10 +49,22 @@ from .types import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AnthropicAdapter",
|
||||
"Block",
|
||||
"Capabilities",
|
||||
"ChainResolver",
|
||||
"CircuitBreaker",
|
||||
"Delta",
|
||||
"Gateway",
|
||||
"GeminiAdapter",
|
||||
"KIMI_CLI_VERSION",
|
||||
"KIMI_CODE_BASE_URL",
|
||||
"KIMI_CODE_KEY_PROVIDER",
|
||||
"KIMI_CODE_PLATFORM",
|
||||
"KIMI_CODE_PROVIDER",
|
||||
"KIMI_CODE_USER_AGENT",
|
||||
"KimiCodeAdapter",
|
||||
"KimiCodeKeyAdapter",
|
||||
"LedgerSink",
|
||||
"LlmRequest",
|
||||
"LlmResponse",
|
||||
@@ -42,6 +78,15 @@ __all__ = [
|
||||
"SqlAlchemyLedgerSink",
|
||||
"StreamChunk",
|
||||
"Tier",
|
||||
"TransientProviderError",
|
||||
"Usage",
|
||||
"build_adapter",
|
||||
"build_kimi_code_client",
|
||||
"build_kimi_code_key_client",
|
||||
"chain_from_routing",
|
||||
"kimi_code_headers",
|
||||
"kimi_device_id",
|
||||
"kimi_device_model",
|
||||
"resolve_chain",
|
||||
"resolve_route",
|
||||
]
|
||||
|
||||
184
packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py
Normal file
184
packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Anthropic(Claude)适配器(ARCH §4.2/§4.4/§4.6)。
|
||||
|
||||
- 能力:原生结构化输出(经 instructor)、显式前缀缓存(`cache_control` 断点)、思考。
|
||||
- 网络客户端注入(`AnthropicClient` Protocol,`AsyncAnthropic` 即满足)——测试注入替身,
|
||||
绝不联网;真实 SDK 仅在构造客户端时(apps/api)需要,本模块不硬 import `anthropic`。
|
||||
- 瞬时故障(429/超时/5xx/连接错误)翻译为 `TransientProviderError`,交网关退避/回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast
|
||||
|
||||
import instructor
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
# 瞬时错误类名(按名匹配,避免硬依赖 anthropic SDK 类型)。
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"InternalServerError",
|
||||
"APIStatusError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AnthropicClient(Protocol):
|
||||
"""`AsyncAnthropic` 满足此 Protocol(仅用到 `messages`)。"""
|
||||
|
||||
messages: Any
|
||||
|
||||
|
||||
class StructuredAnthropic(Protocol):
|
||||
async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: ...
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]:
|
||||
"""system 块;缓存断点前的稳定块带 `cache_control`(§4.6)。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for b in req.system:
|
||||
block: dict[str, Any] = {"type": "text", "text": b.text}
|
||||
if b.cache:
|
||||
block["cache_control"] = {"type": "ephemeral"}
|
||||
out.append(block)
|
||||
return out
|
||||
|
||||
|
||||
def _input_text(req: LlmRequest) -> str:
|
||||
if isinstance(req.input, str):
|
||||
return req.input
|
||||
return "\n\n".join(b.text for b in req.input)
|
||||
|
||||
|
||||
def _user_messages(req: LlmRequest) -> list[dict[str, Any]]:
|
||||
return [{"role": "user", "content": _input_text(req)}]
|
||||
|
||||
|
||||
def _usage_from(usage: Any) -> ProviderUsage:
|
||||
if usage is None:
|
||||
return ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderUsage(
|
||||
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
|
||||
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
|
||||
cache_read_tokens=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def _text_from(resp: Any) -> str:
|
||||
parts: list[str] = []
|
||||
for block in getattr(resp, "content", []) or []:
|
||||
if getattr(block, "type", None) == "text":
|
||||
parts.append(getattr(block, "text", "") or "")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_DEFAULT_MAX_TOKENS = 4096
|
||||
|
||||
|
||||
class AnthropicAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
client: AnthropicClient,
|
||||
*,
|
||||
structured_client: StructuredAnthropic | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self._client = client
|
||||
self._structured_client = structured_client
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=True, thinking=True)
|
||||
|
||||
def _structured(self) -> StructuredAnthropic:
|
||||
if self._structured_client is None:
|
||||
# 注入客户端是 `AnthropicClient` Protocol(保留测试替身缝),
|
||||
# 但 instructor.from_anthropic 只重载真实 SDK 的具体类型;
|
||||
# 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载,
|
||||
# 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。
|
||||
async_client = cast("AsyncAnthropic", self._client)
|
||||
self._structured_client = cast(
|
||||
"StructuredAnthropic", instructor.from_anthropic(async_client)
|
||||
)
|
||||
return self._structured_client
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
try:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
resp = await self._client.messages.create(**kwargs)
|
||||
usage = _usage_from(getattr(resp, "usage", None))
|
||||
return ProviderResult(text=_text_from(resp), usage=usage)
|
||||
|
||||
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
assert req.output_schema is not None
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
parsed = await self._structured().create(response_model=req.output_schema, **kwargs)
|
||||
# instructor.from_anthropic 默认不回 raw usage(按名隐藏);usage 经流/text 路径覆盖。
|
||||
usage = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
|
||||
"messages": _user_messages(req),
|
||||
}
|
||||
system = _system_blocks(req)
|
||||
if system:
|
||||
kwargs["system"] = system
|
||||
try:
|
||||
async with self._client.messages.stream(**kwargs) as stream:
|
||||
async for event in stream:
|
||||
if getattr(event, "type", None) == "content_block_delta":
|
||||
delta = getattr(event, "delta", None)
|
||||
text = getattr(delta, "text", "") if delta is not None else ""
|
||||
if text:
|
||||
yield StreamChunk(text=text)
|
||||
usage = getattr(event, "usage", None)
|
||||
if usage is not None:
|
||||
yield StreamChunk(usage=_usage_from(usage))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
127
packages/llm_gateway/ww_llm_gateway/adapters/gemini.py
Normal file
127
packages/llm_gateway/ww_llm_gateway/adapters/gemini.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Google Gemini 适配器(ARCH §4.2/§4.4)。
|
||||
|
||||
- 能力:原生结构化输出(`response_mime_type=application/json` + `response_schema`)、思考。
|
||||
前缀缓存走 Gemini 隐式/显式缓存,机制差异大,原型保守标 `prefix_cache=False`。
|
||||
- 客户端注入(`GeminiClient` Protocol,`google.genai.Client().aio` 满足)——测试注入替身,
|
||||
绝不联网;真实 SDK(`google-genai`)仅在 apps/api 构造客户端时需要,本模块不硬 import。
|
||||
- 瞬时故障翻译为 `TransientProviderError`,交网关退避/回退。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"ResourceExhausted",
|
||||
"ServiceUnavailable",
|
||||
"DeadlineExceeded",
|
||||
"InternalServerError",
|
||||
"ServerError",
|
||||
"APIError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GeminiModels(Protocol):
|
||||
async def generate_content(self, **kwargs: Any) -> Any: ...
|
||||
|
||||
def generate_content_stream(self, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
class GeminiAio(Protocol):
|
||||
models: GeminiModels
|
||||
|
||||
|
||||
class GeminiClient(Protocol):
|
||||
"""`google.genai.Client()` 满足此 Protocol(用其 `.aio.models`)。"""
|
||||
|
||||
aio: GeminiAio
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "code", None) or getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
def _contents(req: LlmRequest) -> str:
|
||||
if isinstance(req.input, str):
|
||||
return req.input
|
||||
return "\n\n".join(b.text for b in req.input)
|
||||
|
||||
|
||||
def _system_text(req: LlmRequest) -> str:
|
||||
return "\n\n".join(b.text for b in req.system)
|
||||
|
||||
|
||||
def _usage_from(meta: Any) -> ProviderUsage:
|
||||
if meta is None:
|
||||
return ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
return ProviderUsage(
|
||||
input_tokens=int(getattr(meta, "prompt_token_count", 0) or 0),
|
||||
output_tokens=int(getattr(meta, "candidates_token_count", 0) or 0),
|
||||
cache_read_tokens=int(getattr(meta, "cached_content_token_count", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
def _config(req: LlmRequest) -> dict[str, Any]:
|
||||
cfg: dict[str, Any] = {}
|
||||
system = _system_text(req)
|
||||
if system:
|
||||
cfg["system_instruction"] = system
|
||||
if req.max_tokens is not None:
|
||||
cfg["max_output_tokens"] = req.max_tokens
|
||||
if req.output_schema is not None:
|
||||
cfg["response_mime_type"] = "application/json"
|
||||
cfg["response_schema"] = req.output_schema
|
||||
return cfg
|
||||
|
||||
|
||||
class GeminiAdapter:
|
||||
def __init__(self, provider: str, client: GeminiClient) -> None:
|
||||
self.provider = provider
|
||||
self._client = client
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True, prefix_cache=False, thinking=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
try:
|
||||
resp = await self._client.aio.models.generate_content(
|
||||
model=model, contents=_contents(req), config=_config(req)
|
||||
)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
text = getattr(resp, "text", "") or ""
|
||||
usage = _usage_from(getattr(resp, "usage_metadata", None))
|
||||
if req.output_schema is not None:
|
||||
parsed = req.output_schema.model_validate_json(text)
|
||||
return ProviderResult(text=text, usage=usage, parsed=parsed)
|
||||
return ProviderResult(text=text, usage=usage)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
try:
|
||||
stream = await self._client.aio.models.generate_content_stream(
|
||||
model=model, contents=_contents(req), config=_config(req)
|
||||
)
|
||||
async for chunk in stream:
|
||||
text = getattr(chunk, "text", "") or ""
|
||||
if text:
|
||||
yield StreamChunk(text=text)
|
||||
meta = getattr(chunk, "usage_metadata", None)
|
||||
if meta is not None:
|
||||
yield StreamChunk(usage=_usage_from(meta))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
190
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py
Normal file
190
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Kimi Code 订阅 plan 适配器(K1.2 / PROGRESS K1)。
|
||||
|
||||
Kimi 的 coding 端点是 **OpenAI 兼容** 的,因此本适配器复用 `OpenAICompatAdapter`——
|
||||
区别仅在:① base_url 指向 coding 端点;② 必须携带伪造的官方客户端头集;③ access_token
|
||||
作为 bearer(OpenAI SDK 的 `api_key` 自动发 `Authorization: Bearer <token>`)。
|
||||
|
||||
**伪造头集的真源 = `github.com/ooojustin/opencode-kimi`(`src/headers.ts` + `src/constants.ts`,
|
||||
1:1 镜像 kimi-cli v1.37.0)。** coding API 会校验这 7 个头;任何偏差都会让 Moonshot 后端
|
||||
返回 `access_terminated_error: only available for Coding Agents`(403)。本实现据此发送完整
|
||||
7 头:UA `KimiCLI/1.37.0` + 6 个 `X-Msh-*`。
|
||||
|
||||
注意:早先曾参照 `picassio/pi-kimi-coder`(`extensions/index.ts`,**仅**发 UA、不带 X-Msh-*)
|
||||
把头集裁成 UA-only——那是分歧/错误的参考实现,导致了误修。现已回退到 opencode 规范的完整
|
||||
7 头(见 `memory/gotchas.md`)。
|
||||
|
||||
`access_token` 由 @backend(K1.3 OAuth device 服务)按需刷新后传入;本适配器只接收当前
|
||||
access token 作为 `api_key`,不负责刷新/获取 token。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import instructor
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .openai_compat import OpenAICompatAdapter, StructuredClient
|
||||
|
||||
#: Kimi 订阅 plan 的 coding 推理端点(OpenAI 兼容)。
|
||||
KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1"
|
||||
|
||||
#: CLI 版本(opencode `constants.ts` KIMI_CLI_VERSION,镜像 kimi-cli v1.37.0)。
|
||||
KIMI_CLI_VERSION = "1.37.0"
|
||||
|
||||
#: 伪造的官方客户端 UA(opencode `USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"`)。
|
||||
#: UA 前缀必须精确为 `KimiCLI/<version>`,且 version 必须与 X-Msh-Version 一致。
|
||||
KIMI_CODE_USER_AGENT = f"KimiCLI/{KIMI_CLI_VERSION}"
|
||||
|
||||
#: `X-Msh-Platform` 是字面常量字符串(**不是** OS 名)。
|
||||
KIMI_CODE_PLATFORM = "kimi_cli"
|
||||
|
||||
#: provider 名(与既有 API-key provider `kimi` 分离,便于档位切换)。
|
||||
KIMI_CODE_PROVIDER = "kimi-code"
|
||||
|
||||
#: device-id 覆盖用环境变量(优先级高于持久化文件,便于测试/CI 注入)。
|
||||
KIMI_DEVICE_ID_ENV = "KIMI_DEVICE_ID"
|
||||
|
||||
#: device-id 持久化路径(与 kimi-cli / opencode 共享 `~/.kimi/device_id`,保证单一稳定指纹)。
|
||||
KIMI_DEVICE_ID_DIR = Path.home() / ".kimi"
|
||||
KIMI_DEVICE_ID_PATH = KIMI_DEVICE_ID_DIR / "device_id"
|
||||
|
||||
#: HTTP 头值若含非 ASCII 会被底层 fetch/httpx 拒绝;opencode `asciiHeaderValue` 同样裁剪。
|
||||
_ASCII_FALLBACK = "unknown"
|
||||
|
||||
|
||||
def _ascii_header_value(value: str, fallback: str = _ASCII_FALLBACK) -> str:
|
||||
"""裁掉非可打印 ASCII(`\\x20-\\x7e` 之外)+ 去空白;空则回退。
|
||||
|
||||
镜像 opencode `asciiHeaderValue`(含非 ASCII 的头值会被底层 fetch/httpx 拒绝)。
|
||||
"""
|
||||
sanitized = "".join(ch for ch in value if "\x20" <= ch <= "\x7e").strip()
|
||||
return sanitized or fallback
|
||||
|
||||
|
||||
def kimi_device_id() -> str:
|
||||
"""返回稳定的 device-id(32 位无连字符 UUID4 hex)。
|
||||
|
||||
优先级:① 环境变量 `KIMI_DEVICE_ID`;② 持久化文件 `~/.kimi/device_id`(不存在则生成一次
|
||||
`uuid.uuid4().hex` 写入、之后复用)。跨调用/进程重启保持稳定,**绝不**每次随机。
|
||||
"""
|
||||
env_value = os.environ.get(KIMI_DEVICE_ID_ENV, "").strip()
|
||||
if env_value:
|
||||
return env_value
|
||||
|
||||
if KIMI_DEVICE_ID_PATH.exists():
|
||||
existing = KIMI_DEVICE_ID_PATH.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
KIMI_DEVICE_ID_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
device_id = uuid.uuid4().hex
|
||||
KIMI_DEVICE_ID_PATH.write_text(device_id, encoding="utf-8")
|
||||
KIMI_DEVICE_ID_PATH.chmod(0o600)
|
||||
return device_id
|
||||
|
||||
|
||||
def kimi_device_model() -> str:
|
||||
"""`X-Msh-Device-Model`:镜像 opencode `kimiDeviceModel()` 的 OS 分支格式。
|
||||
|
||||
- macOS:`f"macOS {mac_ver} {machine}"`(如 `"macOS 14.5 arm64"`);
|
||||
- Windows:`f"Windows {release} {machine}"`;
|
||||
- 其它:`f"{system} {release} {machine}"`。
|
||||
`platform.machine()` 返回 `arm64`/`x86_64`(**不**做归一化)。
|
||||
"""
|
||||
system = platform.system()
|
||||
release = platform.release()
|
||||
machine = platform.machine()
|
||||
|
||||
if system == "Darwin":
|
||||
version = platform.mac_ver()[0] or release
|
||||
if version and machine:
|
||||
return f"macOS {version} {machine}"
|
||||
if version:
|
||||
return f"macOS {version}"
|
||||
return f"macOS {machine}".strip()
|
||||
|
||||
if system == "Windows":
|
||||
if release and machine:
|
||||
return f"Windows {release} {machine}"
|
||||
if release:
|
||||
return f"Windows {release}"
|
||||
return f"Windows {machine}".strip()
|
||||
|
||||
if system:
|
||||
if release and machine:
|
||||
return f"{system} {release} {machine}"
|
||||
if release:
|
||||
return f"{system} {release}"
|
||||
return f"{system} {machine}".strip()
|
||||
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _device_name() -> str:
|
||||
"""`X-Msh-Device-Name`:主机名(ASCII 化)。`socket.gethostname()` ≈ Node `os.hostname()`。"""
|
||||
return _ascii_header_value(socket.gethostname() or platform.node())
|
||||
|
||||
|
||||
def _os_version() -> str:
|
||||
"""`X-Msh-Os-Version`:OS 内核版本串。`platform.version()` ≈ Node `os.version()`。"""
|
||||
fallback = f"{platform.system()} {platform.release()}"
|
||||
return _ascii_header_value(platform.version() or fallback)
|
||||
|
||||
|
||||
def kimi_code_headers() -> dict[str, str]:
|
||||
"""组装 Kimi coding API 必需的伪造客户端头集(opencode 规范的完整 7 头)。
|
||||
|
||||
真源:`ooojustin/opencode-kimi` `src/headers.ts` `kimiHeaders()`。
|
||||
"""
|
||||
return {
|
||||
"User-Agent": KIMI_CODE_USER_AGENT,
|
||||
"X-Msh-Platform": KIMI_CODE_PLATFORM,
|
||||
"X-Msh-Version": KIMI_CLI_VERSION,
|
||||
"X-Msh-Device-Name": _device_name(),
|
||||
"X-Msh-Device-Model": _ascii_header_value(kimi_device_model()),
|
||||
"X-Msh-Device-Id": kimi_device_id(),
|
||||
"X-Msh-Os-Version": _os_version(),
|
||||
}
|
||||
|
||||
|
||||
def build_kimi_code_client(access_token: str, *, base_url: str | None = None) -> AsyncOpenAI:
|
||||
"""构造带伪造头的 `AsyncOpenAI` 客户端(access_token → bearer,coding base)。"""
|
||||
return AsyncOpenAI(
|
||||
api_key=access_token,
|
||||
base_url=base_url or KIMI_CODE_BASE_URL,
|
||||
default_headers=kimi_code_headers(),
|
||||
)
|
||||
|
||||
|
||||
def build_kimi_code_structured_client(client: AsyncOpenAI) -> StructuredClient:
|
||||
"""Kimi 结构化输出走 instructor **JSON 模式**(`response_format`),**不**用 TOOLS 模式。
|
||||
|
||||
缘由:`kimi-for-coding` 默认开启 thinking,而 Moonshot 后端对「thinking 开启 + 强制
|
||||
`tool_choice`」组合返回 `400 tool_choice 'specified' is incompatible with thinking enabled`。
|
||||
instructor 默认 `Mode.TOOLS` 会发强制 `tool_choice` → 触发该 400。改用 `Mode.JSON`
|
||||
(`response_format={"type":"json_object"}` + schema 注入 prompt)即避开 tool_choice,
|
||||
与 thinking 兼容。仅对 `kimi-code` 生效,其它 provider 维持默认模式。
|
||||
"""
|
||||
return instructor.from_openai(client, mode=instructor.Mode.JSON)
|
||||
|
||||
|
||||
class KimiCodeAdapter(OpenAICompatAdapter):
|
||||
"""Kimi Code 适配器:复用 OpenAI 兼容行为,固定 provider 名 `kimi-code`。
|
||||
|
||||
model(如 `kimi-for-coding`)经 `.complete(req, model)` 传入(路由/档位关注点),
|
||||
**不**在此硬编码。结构化输出强制走 JSON 模式(见 `build_kimi_code_structured_client`)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
|
||||
) -> None:
|
||||
super().__init__(
|
||||
KIMI_CODE_PROVIDER,
|
||||
client,
|
||||
structured_client=structured_client or build_kimi_code_structured_client(client),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Kimi Code 订阅 plan — 静态 Console API Key 适配器。
|
||||
|
||||
Kimi Code Console(`kimi.com/code/console`)签发的 API Key 走**订阅额度**,命中与 OAuth
|
||||
`kimi-code` **相同**的 coding 端点(`https://api.kimi.com/coding/v1`,OpenAI 兼容)+ 相同
|
||||
model `kimi-for-coding`。
|
||||
|
||||
**实测纠正(2026-06-20)**:coding 端点会校验 `User-Agent`,对非 allow-list 客户端返回
|
||||
`403 access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code,
|
||||
Roo Code, ...`——**无论用静态 key 还是 OAuth token**。即静态 key 仅解决鉴权(无 401),但
|
||||
UA 门禁(403)依旧。故本适配器**也必须发**与 OAuth 相同的伪造客户端头集
|
||||
(`kimi_code_headers()`:`KimiCLI/1.37.0` + `X-Msh-*`),否则 403。早先「纯 key 无需伪造头、
|
||||
ToS 合规」的判断被实测推翻——**两条订阅路径都需 UA 伪造 = 同样的 ToS 违规/封号风险**。本路径
|
||||
相对 OAuth 的唯一优势是用静态 Console key(无 token 刷新机制),风险等同。真正合规的只有
|
||||
按量付费的 Moonshot 平台 key(provider `kimi`)。
|
||||
|
||||
唯一与普通 OpenAI 兼容 provider 的另一区别:coding 端点跑 `kimi-for-coding` 且 **thinking 开启**,
|
||||
Moonshot 后端对「thinking + 强制 `tool_choice`」返回 `400`。因此结构化输出走 instructor
|
||||
**JSON 模式**(复用 `build_kimi_code_structured_client`),而非默认 TOOLS 模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .kimi_code import (
|
||||
KIMI_CODE_BASE_URL,
|
||||
build_kimi_code_structured_client,
|
||||
kimi_code_headers,
|
||||
)
|
||||
from .openai_compat import OpenAICompatAdapter, StructuredClient
|
||||
|
||||
#: provider 名(与 OAuth 的 `kimi-code`、moonshot 的 `kimi` 均分离,便于档位切换)。
|
||||
KIMI_CODE_KEY_PROVIDER = "kimi-code-key"
|
||||
|
||||
|
||||
def build_kimi_code_key_client(api_key: str, *, base_url: str | None = None) -> AsyncOpenAI:
|
||||
"""构造 coding 端点客户端(key → bearer + **伪造客户端头**,coding base)。
|
||||
|
||||
实测:coding 端点据 `User-Agent` 做 allow-list 门禁,缺伪造头 → 403。故与 OAuth 的
|
||||
`build_kimi_code_client` 一样必须带 `default_headers=kimi_code_headers()`
|
||||
(`KimiCLI/1.37.0` + `X-Msh-*`);唯一区别是凭据来源是静态 Console key 而非 OAuth token。
|
||||
"""
|
||||
return AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url or KIMI_CODE_BASE_URL,
|
||||
default_headers=kimi_code_headers(),
|
||||
)
|
||||
|
||||
|
||||
class KimiCodeKeyAdapter(OpenAICompatAdapter):
|
||||
"""Kimi Code 静态 Key 适配器:OpenAI 兼容 + 伪造客户端头 + JSON 模式结构化。
|
||||
|
||||
model(`kimi-for-coding`)经 `.complete(req, model)` 由路由/档位传入,**不**在此硬编码。
|
||||
结构化输出强制走 JSON 模式(thinking 兼容);发与 OAuth 相同的伪造头以过 coding 端点 UA 门禁。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, client: AsyncOpenAI, *, structured_client: StructuredClient | None = None
|
||||
) -> None:
|
||||
super().__init__(
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
client,
|
||||
structured_client=structured_client or build_kimi_code_structured_client(client),
|
||||
)
|
||||
@@ -13,9 +13,29 @@ from openai import AsyncOpenAI
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..errors import TransientProviderError
|
||||
from ..types import LlmRequest
|
||||
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
|
||||
|
||||
# OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK)。
|
||||
_TRANSIENT_NAMES = frozenset(
|
||||
{
|
||||
"RateLimitError",
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"InternalServerError",
|
||||
"APIStatusError",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_transient(exc: Exception) -> bool:
|
||||
name = type(exc).__name__
|
||||
if name in _TRANSIENT_NAMES:
|
||||
return True
|
||||
status = getattr(exc, "status_code", None)
|
||||
return isinstance(status, int) and (status == 429 or status >= 500)
|
||||
|
||||
|
||||
class StructuredClient(Protocol):
|
||||
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
|
||||
@@ -87,9 +107,14 @@ class OpenAICompatAdapter:
|
||||
return self._structured_client
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
try:
|
||||
if req.output_schema is not None:
|
||||
return await self._complete_structured(req, model)
|
||||
return await self._complete_text(req, model)
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
resp = await self._client.chat.completions.create(
|
||||
@@ -113,17 +138,22 @@ class OpenAICompatAdapter:
|
||||
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
yield StreamChunk(text=delta.content)
|
||||
if getattr(chunk, "usage", None):
|
||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||
try:
|
||||
stream = await self._client.chat.completions.create(
|
||||
model=model,
|
||||
messages=_messages(req),
|
||||
max_tokens=req.max_tokens,
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
async for chunk in stream:
|
||||
if chunk.choices:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta and delta.content:
|
||||
yield StreamChunk(text=delta.content)
|
||||
if getattr(chunk, "usage", None):
|
||||
yield StreamChunk(usage=_usage_from(chunk.usage))
|
||||
except Exception as exc:
|
||||
if _is_transient(exc):
|
||||
raise TransientProviderError(str(exc), provider=self.provider) from exc
|
||||
raise
|
||||
|
||||
22
packages/llm_gateway/ww_llm_gateway/errors.py
Normal file
22
packages/llm_gateway/ww_llm_gateway/errors.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""网关内部异常类型(ARCH §4.5)。
|
||||
|
||||
适配器把厂商的可重试故障(限流/超时/5xx)翻译为 `TransientProviderError`,
|
||||
网关据此决定退避重试 / 切回退链。非瞬时错误(内容策略拒绝等)由适配器抛
|
||||
`AppError` 或原样上抛,网关不盲目重试。
|
||||
|
||||
`ww_shared.AppError(ErrorCode.RATE_LIMITED)` 也被网关视作可重试/可回退(限流即瞬时)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TransientProviderError(Exception):
|
||||
"""瞬时、可重试的 provider 故障(429/超时/5xx/连接错误)。
|
||||
|
||||
适配器在 `except` 厂商异常时包装抛出,携带原始 provider 名便于日志/熔断归因。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, provider: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.provider = provider
|
||||
77
packages/llm_gateway/ww_llm_gateway/factory.py
Normal file
77
packages/llm_gateway/ww_llm_gateway/factory.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Provider→适配器工厂(ARCH §4.2/§4.3)。
|
||||
|
||||
`build_gateway_for_tier`(apps/api)据 DB `tier_routing` 的 primary + fallback 逐 provider
|
||||
建适配器进 `adapters` dict——本工厂按 provider 名分派到对应适配器类,构造其真实客户端。
|
||||
|
||||
- `anthropic` → `AnthropicAdapter`(懒构建 `AsyncAnthropic`,不在模块顶层硬 import SDK)。
|
||||
- `gemini`/`google` → `GeminiAdapter`(懒构建 `google.genai.Client`)。
|
||||
- `kimi-code` → `KimiCodeAdapter`(OAuth access token 当 api_key + coding base + 伪造头)。
|
||||
- `kimi-code-key` → `KimiCodeKeyAdapter`(静态 Console Key + coding base,**无伪造头**)。
|
||||
- 其余(deepseek/kimi/qwen/glm/openai)→ `OpenAICompatAdapter(provider, AsyncOpenAI(...))`。
|
||||
|
||||
适配器本身仍接注入客户端(测试零联网);本工厂从 api_key 构造**真实**客户端,
|
||||
故单测只断分派逻辑(按 provider 名选哪个适配器类),不联网。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .adapters.anthropic import AnthropicAdapter, AnthropicClient
|
||||
from .adapters.base import ProviderAdapter
|
||||
from .adapters.gemini import GeminiAdapter, GeminiClient
|
||||
from .adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter, build_kimi_code_client
|
||||
from .adapters.kimi_code_key import (
|
||||
KIMI_CODE_KEY_PROVIDER,
|
||||
KimiCodeKeyAdapter,
|
||||
build_kimi_code_key_client,
|
||||
)
|
||||
from .adapters.openai_compat import OpenAICompatAdapter
|
||||
|
||||
# 走专用 SDK 适配器的 provider 名(其余一律走 OpenAI 兼容)。
|
||||
_ANTHROPIC_PROVIDERS = frozenset({"anthropic"})
|
||||
_GEMINI_PROVIDERS = frozenset({"gemini", "google"})
|
||||
|
||||
|
||||
def _build_anthropic(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
|
||||
# 懒 import:SDK 仅在真实接线 Anthropic 时才需要(单测不触发此分支)。
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
kwargs: dict[str, object] = {"api_key": api_key}
|
||||
if base_url is not None:
|
||||
kwargs["base_url"] = base_url
|
||||
client = AsyncAnthropic(**kwargs) # type: ignore[arg-type]
|
||||
# `AsyncAnthropic` 满足 `AnthropicClient` Protocol(read-only `messages` 不影响鸭子用法);
|
||||
# cast 跨过 Protocol settable-attr 静态约束(适配器仅读取该属性)。
|
||||
return AnthropicAdapter(provider, cast("AnthropicClient", client))
|
||||
|
||||
|
||||
def _build_gemini(provider: str, *, api_key: str, base_url: str | None) -> ProviderAdapter:
|
||||
# 懒 import:`google-genai` 仅在真实接线 Gemini 时才需要。
|
||||
from google import genai
|
||||
|
||||
client = genai.Client(api_key=api_key)
|
||||
# `genai.Client` 满足 `GeminiClient` Protocol(用其 `.aio.models`);cast 跨过
|
||||
# Protocol settable-attr 静态约束(适配器仅读取 `.aio`)。
|
||||
return GeminiAdapter(provider, cast("GeminiClient", client))
|
||||
|
||||
|
||||
def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter:
|
||||
"""按 provider 名构造对应适配器,从 api_key(+可选 base_url)建真实客户端。
|
||||
|
||||
`build_gateway_for_tier`(apps/api)逐 provider 调用本工厂,组装回退链的 adapters。
|
||||
"""
|
||||
if provider == KIMI_CODE_PROVIDER:
|
||||
# Kimi 订阅 plan(OAuth):api_key = OAuth access token;coding base + 伪造头(缺头 403)。
|
||||
return KimiCodeAdapter(build_kimi_code_client(api_key, base_url=base_url))
|
||||
if provider == KIMI_CODE_KEY_PROVIDER:
|
||||
# Kimi 订阅 plan(静态 Console Key,ToS 合规):coding base + 纯 bearer,**无伪造头**;
|
||||
# 结构化走 JSON 模式(thinking 兼容)。
|
||||
return KimiCodeKeyAdapter(build_kimi_code_key_client(api_key, base_url=base_url))
|
||||
if provider in _ANTHROPIC_PROVIDERS:
|
||||
return _build_anthropic(provider, api_key=api_key, base_url=base_url)
|
||||
if provider in _GEMINI_PROVIDERS:
|
||||
return _build_gemini(provider, api_key=api_key, base_url=base_url)
|
||||
return OpenAICompatAdapter(provider, AsyncOpenAI(api_key=api_key, base_url=base_url))
|
||||
@@ -1,24 +1,56 @@
|
||||
"""网关核心:路由 → 调用适配器 → 记账 → 返回(ARCH §4.1–4.3/§4.8)。
|
||||
"""网关核心:路由 → 回退链 → 重试/熔断 → 调用适配器 → 记账 → 返回。
|
||||
|
||||
ARCH §4.3(路由)/§4.4(能力协商降级)/§4.5(回退/重试/熔断)/§4.8(记账)。
|
||||
|
||||
T5.4 多 provider 韧性:
|
||||
- **回退链**:`chain_resolver(tier)` 返 [(provider, model), ...];主失败→退避重试→
|
||||
仍失败切下一个;首个成功者服务并标 `served_by.fell_back`(非链首即 True)。
|
||||
- **熔断**:`CircuitBreaker` 按 provider 连续失败计数,超阈值短时熔断、直接跳过。
|
||||
- **能力降级**:结构化输出请求优先选声明支持的 provider;链上无支持者则降级用
|
||||
首个可用(适配器自走 instructor JSON 提示),标 `served_by.degraded`。
|
||||
- **重试归网关**(不变量):瞬时错误(TransientProviderError / RATE_LIMITED)经
|
||||
tenacity 指数退避重试 R 次;节点只感知干净的最终失败,绝不自循环。
|
||||
|
||||
M1 单 provider,无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。
|
||||
日志脱敏:只记长度,绝不记原文/api key(不变量、§9.3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
import structlog
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from .adapters.base import ProviderAdapter, ProviderUsage
|
||||
from .adapters.base import ProviderAdapter, ProviderResult, ProviderUsage
|
||||
from .errors import TransientProviderError
|
||||
from .ledger import LedgerSink
|
||||
from .pricing import cost_minor
|
||||
from .routing import Route, resolve_route
|
||||
from .routing import Route, resolve_chain
|
||||
from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 退避:min 0.05s、max 2s(确定性测试通过 max_retries 控制次数,退避不影响断言)。
|
||||
_RETRY_MIN_SECONDS = 0.05
|
||||
_RETRY_MAX_SECONDS = 2.0
|
||||
_DEFAULT_MAX_RETRIES = 2
|
||||
_DEFAULT_BREAKER_THRESHOLD = 5
|
||||
_DEFAULT_BREAKER_RESET_SECONDS = 30.0
|
||||
|
||||
|
||||
def _is_retryable(exc: BaseException) -> bool:
|
||||
"""瞬时、可退避重试 + 可回退的错误:TransientProviderError 或 RATE_LIMITED。"""
|
||||
if isinstance(exc, TransientProviderError):
|
||||
return True
|
||||
return isinstance(exc, AppError) and exc.code == ErrorCode.RATE_LIMITED
|
||||
|
||||
|
||||
def _input_len(req: LlmRequest) -> int:
|
||||
if isinstance(req.input, str):
|
||||
@@ -26,22 +58,92 @@ def _input_len(req: LlmRequest) -> int:
|
||||
return sum(len(b.text) for b in req.input)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""每 provider 连续失败计数 → 超阈值短时熔断;成功清零;冷却后半开放行。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
threshold: int = _DEFAULT_BREAKER_THRESHOLD,
|
||||
reset_seconds: float = _DEFAULT_BREAKER_RESET_SECONDS,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._threshold = threshold
|
||||
self._reset_seconds = reset_seconds
|
||||
self._clock = clock
|
||||
self._failures: dict[str, int] = {}
|
||||
self._opened_at: dict[str, float] = {}
|
||||
|
||||
def is_open(self, provider: str) -> bool:
|
||||
opened = self._opened_at.get(provider)
|
||||
if opened is None:
|
||||
return False
|
||||
if self._clock() - opened >= self._reset_seconds:
|
||||
# 冷却窗口过 → 半开:清状态、放行一次试探。
|
||||
self._opened_at.pop(provider, None)
|
||||
self._failures.pop(provider, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def record_failure(self, provider: str) -> None:
|
||||
count = self._failures.get(provider, 0) + 1
|
||||
self._failures[provider] = count
|
||||
if count >= self._threshold:
|
||||
self._opened_at[provider] = self._clock()
|
||||
|
||||
def record_success(self, provider: str) -> None:
|
||||
self._failures.pop(provider, None)
|
||||
self._opened_at.pop(provider, None)
|
||||
|
||||
|
||||
class Gateway:
|
||||
def __init__(
|
||||
self,
|
||||
adapters: dict[str, ProviderAdapter],
|
||||
ledger: LedgerSink,
|
||||
resolver: Callable[[Tier], Route] = resolve_route,
|
||||
*,
|
||||
chain_resolver: Callable[[Tier], list[Route]] | None = None,
|
||||
resolver: Callable[[Tier], Route] | None = None,
|
||||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||||
breaker: CircuitBreaker | None = None,
|
||||
) -> None:
|
||||
self._adapters = adapters
|
||||
self._ledger = ledger
|
||||
self._resolve = resolver
|
||||
# `resolver`(单路由,C1/M1 兼容)会被包成单元素链;优先用 `chain_resolver`(回退链)。
|
||||
if chain_resolver is not None:
|
||||
self._resolve_chain = chain_resolver
|
||||
elif resolver is not None:
|
||||
single = resolver
|
||||
|
||||
def _adapter_for(self, provider: str) -> ProviderAdapter:
|
||||
adapter = self._adapters.get(provider)
|
||||
if adapter is None:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}")
|
||||
return adapter
|
||||
def _as_chain(tier: Tier) -> list[Route]:
|
||||
return [single(tier)]
|
||||
|
||||
self._resolve_chain = _as_chain
|
||||
else:
|
||||
self._resolve_chain = resolve_chain
|
||||
self._max_retries = max_retries
|
||||
self._breaker = breaker or CircuitBreaker()
|
||||
|
||||
# ---- 路由 / 链选择 ----
|
||||
|
||||
def _ordered_chain(self, req: LlmRequest) -> list[Route]:
|
||||
"""据能力协商重排回退链:结构化输出请求优先把声明支持的 provider 提前。
|
||||
|
||||
仅在请求带 `output_schema` 时重排——把支持结构化输出的(且已注册适配器、未熔断)
|
||||
provider 提到前面,降低降级概率(§4.4)。保持相对顺序稳定。
|
||||
"""
|
||||
chain = self._resolve_chain(req.tier)
|
||||
if req.output_schema is None:
|
||||
return chain
|
||||
capable: list[Route] = []
|
||||
rest: list[Route] = []
|
||||
for route in chain:
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is not None and adapter.capabilities().structured_output:
|
||||
capable.append(route)
|
||||
else:
|
||||
rest.append(route)
|
||||
return capable + rest
|
||||
|
||||
def _usage(self, route: Route, pu: ProviderUsage) -> Usage:
|
||||
cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens)
|
||||
@@ -55,7 +157,19 @@ class Gateway:
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None:
|
||||
def _served_by(
|
||||
self, route: Route, req: LlmRequest, adapter: ProviderAdapter, *, fell_back: bool
|
||||
) -> ServedBy:
|
||||
degraded = bool(
|
||||
req.output_schema is not None and not adapter.capabilities().structured_output
|
||||
)
|
||||
return ServedBy(
|
||||
provider=route.provider, model=route.model, fell_back=fell_back, degraded=degraded
|
||||
)
|
||||
|
||||
def _log_call(
|
||||
self, req: LlmRequest, usage: Usage, served_by: ServedBy, *, stream: bool
|
||||
) -> None:
|
||||
log.info(
|
||||
"llm_call",
|
||||
provider=usage.provider,
|
||||
@@ -67,33 +181,116 @@ class Gateway:
|
||||
cost_minor=usage.cost_minor,
|
||||
currency=usage.currency,
|
||||
stream=stream,
|
||||
fell_back=served_by.fell_back,
|
||||
degraded=served_by.degraded,
|
||||
input_chars=_input_len(req),
|
||||
project_id=str(req.scope.project_id) if req.scope.project_id else None,
|
||||
)
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
route = self._resolve(req.tier)
|
||||
adapter = self._adapter_for(route.provider)
|
||||
result = await adapter.complete(req, route.model)
|
||||
usage = self._usage(route, result.usage)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, stream=False)
|
||||
return LlmResponse(
|
||||
text=result.text,
|
||||
parsed=result.parsed,
|
||||
usage=usage,
|
||||
served_by=ServedBy(provider=route.provider, model=route.model),
|
||||
async def _retrying(self) -> AsyncRetrying:
|
||||
return AsyncRetrying(
|
||||
stop=stop_after_attempt(self._max_retries + 1),
|
||||
wait=wait_exponential(min=_RETRY_MIN_SECONDS, max=_RETRY_MAX_SECONDS),
|
||||
retry=retry_if_exception(_is_retryable),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
# ---- run(非流式)----
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
chain = self._ordered_chain(req)
|
||||
last_error: Exception | None = None
|
||||
for idx, route in enumerate(chain):
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is None:
|
||||
log.warning("llm_route_no_adapter", provider=route.provider, tier=req.tier)
|
||||
continue
|
||||
if self._breaker.is_open(route.provider):
|
||||
log.warning("llm_circuit_open_skip", provider=route.provider, tier=req.tier)
|
||||
continue
|
||||
try:
|
||||
result = await self._complete_with_retry(adapter, req, route.model)
|
||||
except Exception as exc: # noqa: BLE001 — 链内逐 provider 兜底,最终统一上抛
|
||||
if not _is_retryable(exc):
|
||||
raise
|
||||
self._breaker.record_failure(route.provider)
|
||||
last_error = exc
|
||||
log.warning(
|
||||
"llm_provider_failed",
|
||||
provider=route.provider,
|
||||
tier=req.tier,
|
||||
error=type(exc).__name__,
|
||||
)
|
||||
continue
|
||||
self._breaker.record_success(route.provider)
|
||||
fell_back = idx > 0
|
||||
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
|
||||
usage = self._usage(route, result.usage)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, served_by, stream=False)
|
||||
return LlmResponse(
|
||||
text=result.text, parsed=result.parsed, usage=usage, served_by=served_by
|
||||
)
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"所有 provider 回退链耗尽,请稍后重试或更换档位",
|
||||
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
|
||||
)
|
||||
|
||||
async def _complete_with_retry(
|
||||
self, adapter: ProviderAdapter, req: LlmRequest, model: str
|
||||
) -> ProviderResult:
|
||||
retrying = await self._retrying()
|
||||
async for attempt in retrying:
|
||||
with attempt:
|
||||
return await adapter.complete(req, model)
|
||||
raise AssertionError("unreachable: reraise=True")
|
||||
|
||||
# ---- stream(流式)----
|
||||
|
||||
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
|
||||
route = self._resolve(req.tier)
|
||||
adapter = self._adapter_for(route.provider)
|
||||
final = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
async for chunk in adapter.stream(req, route.model):
|
||||
if chunk.text:
|
||||
yield Delta(text=chunk.text)
|
||||
if chunk.usage is not None:
|
||||
final = chunk.usage
|
||||
usage = self._usage(route, final)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, stream=True)
|
||||
chain = self._ordered_chain(req)
|
||||
last_error: Exception | None = None
|
||||
for idx, route in enumerate(chain):
|
||||
adapter = self._adapters.get(route.provider)
|
||||
if adapter is None:
|
||||
continue
|
||||
if self._breaker.is_open(route.provider):
|
||||
continue
|
||||
# 流式回退:仅在「尚未吐出任何 token」前可切。首块产出后失败属中途失败,
|
||||
# 不静默重连(§4.5:已存部分留 draft,节点报错停在 write 前 checkpoint)。
|
||||
try:
|
||||
stream_iter = adapter.stream(req, route.model)
|
||||
final = ProviderUsage(input_tokens=0, output_tokens=0)
|
||||
started = False
|
||||
async for chunk in stream_iter:
|
||||
if chunk.text:
|
||||
started = True
|
||||
yield Delta(text=chunk.text)
|
||||
if chunk.usage is not None:
|
||||
final = chunk.usage
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if started or not _is_retryable(exc):
|
||||
raise
|
||||
self._breaker.record_failure(route.provider)
|
||||
last_error = exc
|
||||
log.warning(
|
||||
"llm_provider_failed",
|
||||
provider=route.provider,
|
||||
tier=req.tier,
|
||||
error=type(exc).__name__,
|
||||
stream=True,
|
||||
)
|
||||
continue
|
||||
self._breaker.record_success(route.provider)
|
||||
fell_back = idx > 0
|
||||
served_by = self._served_by(route, req, adapter, fell_back=fell_back)
|
||||
usage = self._usage(route, final)
|
||||
await self._ledger.record(req.scope, usage)
|
||||
self._log_call(req, usage, served_by, stream=True)
|
||||
return
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"所有 provider 回退链耗尽,请稍后重试或更换档位",
|
||||
{"tier": req.tier, "last_error": type(last_error).__name__ if last_error else None},
|
||||
)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""档位路由:tier -> (provider, model)(ARCH §4.3)。
|
||||
"""档位路由:tier -> 回退链 [(provider, model), ...](ARCH §4.3/§4.5)。
|
||||
|
||||
M1 只读全局默认(config.tier_defaults,形如 "deepseek:deepseek-chat");
|
||||
作品级 / Skill 级覆盖留待后续(§4.3 三级解析)。
|
||||
- `resolve_route(tier)`:单路由(M1 兼容;返回链首)。
|
||||
- `resolve_chain(tier)`:回退链。默认实现只读全局 `config.tier_defaults`(单元素链)。
|
||||
真正的多 provider 回退链由 apps/api 按 DB `tier_routing.fallback` 构造一个
|
||||
`ChainResolver` 注入网关(§4.3 三级解析 + §4.5 回退链)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ww_config import get_settings
|
||||
@@ -19,11 +22,42 @@ class Route:
|
||||
model: str
|
||||
|
||||
|
||||
def resolve_route(tier: Tier) -> Route:
|
||||
spec = get_settings().tier_defaults.get(tier)
|
||||
if not spec:
|
||||
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
|
||||
# 解析器缝:tier -> 回退链。apps/api 注入据 DB tier_routing 构造的实现。
|
||||
ChainResolver = Callable[[Tier], "list[Route]"]
|
||||
|
||||
|
||||
def _parse_spec(spec: str) -> Route:
|
||||
provider, sep, model = spec.partition(":")
|
||||
if not sep or not provider or not model:
|
||||
raise ValueError(f"invalid tier route {spec!r}; expected 'provider:model'")
|
||||
return Route(provider=provider, model=model)
|
||||
|
||||
|
||||
def resolve_route(tier: Tier) -> Route:
|
||||
spec = get_settings().tier_defaults.get(tier)
|
||||
if not spec:
|
||||
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
|
||||
return _parse_spec(spec)
|
||||
|
||||
|
||||
def resolve_chain(tier: Tier) -> list[Route]:
|
||||
"""默认链解析:仅全局默认(单元素)。多 provider 回退由注入的 `ChainResolver` 提供。"""
|
||||
return [resolve_route(tier)]
|
||||
|
||||
|
||||
def chain_from_routing(tier: Tier, primary: str, fallback: list[str]) -> list[Route]:
|
||||
"""据 DB `tier_routing` 行(primary `provider:model` + fallback 列表)构造回退链。
|
||||
|
||||
供 apps/api 包成 `ChainResolver` 注入网关;放这里以便单测覆盖解析逻辑。
|
||||
去重保序:同一 (provider, model) 只保留首次出现。
|
||||
"""
|
||||
routes: list[Route] = [_parse_spec(primary)]
|
||||
routes.extend(_parse_spec(s) for s in fallback)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique: list[Route] = []
|
||||
for r in routes:
|
||||
key = (r.provider, r.model)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(r)
|
||||
return unique
|
||||
|
||||
@@ -56,11 +56,17 @@ class Usage(BaseModel):
|
||||
|
||||
|
||||
class ServedBy(BaseModel):
|
||||
"""实际服务方;`fell_back` 标记是否走了回退链(M5 才有回退,M1 恒 False)。"""
|
||||
"""实际服务方(C1 扩,T5.4)。
|
||||
|
||||
- `fell_back`:是否走了回退链(主模型失败/熔断后由链上后续 provider 服务)。
|
||||
- `degraded`:是否发生能力降级(如所选 provider 不支持原生结构化输出,
|
||||
改走 instructor JSON-提示路径)。两者仅观测/记账标注,对上层正确性透明。
|
||||
"""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
fell_back: bool = False
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class LlmResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user