"""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