From f9afc9389b4eec6278fe5e377fc85b5b3379d29c Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:34:42 +0800 Subject: [PATCH] fix(providers): apply Kimi Coding default headers --- docs/configuration.md | 2 +- docs/provider-cookbook.md | 9 ++-- nanobot/providers/factory.py | 28 ++++++++--- nanobot/providers/registry.py | 4 +- .../test_provider_default_headers.py | 50 +++++++++++++++++++ 5 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 tests/providers/test_provider_default_headers.py diff --git a/docs/configuration.md b/docs/configuration.md index 3f00f027..32a81d08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -230,7 +230,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client > - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) ยท [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link) > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`. -> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint **requires** `extraHeaders.User-Agent` set to a Claude-compatible value such as `claude-code/0.1.0`; omitting it returns `403`. +> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value. > - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers. > - **OpenCode Zen / Go**: `providers.opencodeZen` and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. diff --git a/docs/provider-cookbook.md b/docs/provider-cookbook.md index 8b8a91a7..afa3e178 100644 --- a/docs/provider-cookbook.md +++ b/docs/provider-cookbook.md @@ -18,7 +18,7 @@ Match the recipe to the credential or endpoint you already have: | An OpenCode Zen or Go key | [OpenCode Zen or Go](#recipe-opencode-zen-or-go) | `OPENCODE_API_KEY`, the Zen/Go provider key, and a model ID from the matching OpenCode endpoint | | An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account | | An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID | -| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, `model: "kimi-for-coding"`, and `extraHeaders.User-Agent` | +| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, and `model: "kimi-for-coding"` | | An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint | | Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability | | vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name | @@ -281,10 +281,7 @@ This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobo { "providers": { "kimiCoding": { - "apiKey": "${KIMI_CODING_API_KEY}", - "extraHeaders": { - "User-Agent": "claude-code/0.1.0" - } + "apiKey": "${KIMI_CODING_API_KEY}" } }, "modelPresets": { @@ -311,7 +308,7 @@ nanobot status nanobot agent -m "Hello!" ``` -The default base URL is `https://api.kimi.com/coding/v1`. This endpoint **requires** the `User-Agent` header; omitting it returns `403`. Keep the value above or use another Claude-compatible value accepted by your Kimi account. +The default base URL is `https://api.kimi.com/coding/v1`. This endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default. If your account requires a different value, override it with `providers.kimiCoding.extraHeaders.User-Agent`. ## Recipe: Custom OpenAI-Compatible Provider diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index a6c309d4..6201c07b 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -5,10 +5,10 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig +from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig, ProviderConfig from nanobot.providers.base import LLMProvider from nanobot.providers.fallback_provider import FallbackProvider -from nanobot.providers.registry import create_dynamic_spec, find_by_name +from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name @dataclass(frozen=True) @@ -28,6 +28,16 @@ def _resolve_model_preset( return preset if preset is not None else config.resolve_preset(preset_name) +def _provider_extra_headers( + spec: ProviderSpec | None, + provider_config: ProviderConfig | None, +) -> dict[str, str] | None: + headers = dict(spec.default_extra_headers) if spec else {} + if provider_config and provider_config.extra_headers: + headers.update(provider_config.extra_headers) + return headers or None + + def _make_provider_core( config: Config, *, @@ -89,7 +99,7 @@ def _make_provider_core( api_key=p.api_key if p else None, api_base=config.get_api_base(model, preset=resolved), default_model=model, - extra_headers=p.extra_headers if p else None, + extra_headers=_provider_extra_headers(spec, p), ) elif backend == "bedrock": from nanobot.providers.bedrock_provider import BedrockProvider @@ -109,7 +119,7 @@ def _make_provider_core( api_key=p.api_key if p else None, api_base=config.get_api_base(model, preset=resolved), default_model=model, - extra_headers=p.extra_headers if p else None, + extra_headers=_provider_extra_headers(spec, p), spec=spec, extra_body=p.extra_body if p else None, api_type=p.api_type if p and provider_name == "openai" else "auto", @@ -191,13 +201,14 @@ def provider_signature( def _fallback_signature(fallback: ModelPresetConfig) -> tuple[object, ...]: fp = config.get_provider(fallback.model, preset=fallback) + provider_name = config.get_provider_name(fallback.model, preset=fallback) return ( fallback.model, fallback.provider, - config.get_provider_name(fallback.model, preset=fallback), + provider_name, config.get_api_key(fallback.model, preset=fallback), config.get_api_base(fallback.model, preset=fallback), - fp.extra_headers if fp else None, + _provider_extra_headers(find_by_name(provider_name) if provider_name else None, fp), fp.extra_body if fp else None, fp.api_type if fp else "auto", fp.extra_query if fp else None, @@ -209,13 +220,14 @@ def provider_signature( fallback.context_window_tokens, ) + provider_name = config.get_provider_name(resolved.model, preset=resolved) return ( resolved.model, resolved.provider, - config.get_provider_name(resolved.model, preset=resolved), + provider_name, config.get_api_key(resolved.model, preset=resolved), config.get_api_base(resolved.model, preset=resolved), - p.extra_headers if p else None, + _provider_extra_headers(find_by_name(provider_name) if provider_name else None, p), p.extra_body if p else None, p.api_type if p else "auto", p.extra_query if p else None, diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 8c44361e..cbfe9ba7 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -37,8 +37,9 @@ class ProviderSpec: # "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock" backend: str = "openai_compat" - # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) + # extra env vars / request headers supplied by the provider integration. env_extras: tuple[tuple[str, str], ...] = () + default_extra_headers: tuple[tuple[str, str], ...] = () # gateway / local detection is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) @@ -426,6 +427,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( display_name="Kimi Coding", backend="anthropic", default_api_base="https://api.kimi.com/coding/v1", + default_extra_headers=(("User-Agent", "claude-code/0.1.0"),), ), # MiniMax: OpenAI-compatible API ProviderSpec( diff --git a/tests/providers/test_provider_default_headers.py b/tests/providers/test_provider_default_headers.py new file mode 100644 index 00000000..fcac92ea --- /dev/null +++ b/tests/providers/test_provider_default_headers.py @@ -0,0 +1,50 @@ +from nanobot.config.schema import Config, ProviderConfig +from nanobot.providers.factory import _provider_extra_headers, provider_signature +from nanobot.providers.registry import find_by_name + + +def test_kimi_coding_uses_default_user_agent_header() -> None: + spec = find_by_name("kimi_coding") + + assert spec is not None + assert _provider_extra_headers(spec, ProviderConfig()) == { + "User-Agent": "claude-code/0.1.0", + } + + +def test_provider_config_extra_headers_override_defaults() -> None: + spec = find_by_name("kimi_coding") + provider = ProviderConfig.model_validate({ + "extraHeaders": { + "User-Agent": "custom-client/1.0", + "X-Test": "1", + }, + }) + + assert _provider_extra_headers(spec, provider) == { + "User-Agent": "custom-client/1.0", + "X-Test": "1", + } + + +def test_provider_signature_tracks_default_extra_headers() -> None: + config = Config.model_validate({ + "providers": { + "kimiCoding": { + "apiKey": "sk-kimi-test", + }, + }, + "modelPresets": { + "primary": { + "provider": "kimi_coding", + "model": "kimi-for-coding", + }, + }, + "agents": { + "defaults": { + "modelPreset": "primary", + }, + }, + }) + + assert {"User-Agent": "claude-code/0.1.0"} in provider_signature(config)