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:
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
|
||||
|
||||
Reference in New Issue
Block a user