diff --git a/providers/byteplus-coding.toml b/providers/byteplus-coding.toml index 1ed0cc1..b4c001d 100644 --- a/providers/byteplus-coding.toml +++ b/providers/byteplus-coding.toml @@ -95,6 +95,7 @@ input_cost_per_m = 0.44 output_cost_per_m = 2.0 supports_tools = true supports_streaming = true +reasoning_echo_policy = "empty_string" aliases = [] [[models]] diff --git a/providers/deepseek.toml b/providers/deepseek.toml index 4573f6f..a8f0fe7 100644 --- a/providers/deepseek.toml +++ b/providers/deepseek.toml @@ -34,6 +34,10 @@ supports_tools = true supports_vision = false supports_streaming = true supports_thinking = true +# Thinking mode is on by default and the API rejects multi-turn requests +# where assistant turns containing tool_calls don't echo back the original +# reasoning_content. See librefang/librefang#4842. +reasoning_echo_policy = "echo" aliases = ["deepseek-flash"] [[models]] @@ -61,4 +65,7 @@ supports_tools = false supports_vision = false supports_streaming = true supports_thinking = true +# R1 returns reasoning_content in responses but the API rejects multi-turn +# requests that carry it on previous assistant messages — drivers must strip. +reasoning_echo_policy = "strip" aliases = ["deepseek-r1"] diff --git a/providers/kimi-coding.toml b/providers/kimi-coding.toml index ff16666..c127bb1 100644 --- a/providers/kimi-coding.toml +++ b/providers/kimi-coding.toml @@ -21,4 +21,5 @@ output_cost_per_m = 0.0 supports_tools = true supports_vision = true supports_streaming = true +reasoning_echo_policy = "empty_string" aliases = [] diff --git a/providers/moonshot.toml b/providers/moonshot.toml index 49fdef4..9c0434a 100644 --- a/providers/moonshot.toml +++ b/providers/moonshot.toml @@ -20,6 +20,10 @@ supports_tools = true supports_vision = true supports_streaming = true supports_thinking = true +# Kimi requires reasoning_content present (empty string) on assistant +# turns with tool_calls, with thinking disabled wire-side for multi-turn +# compatibility. See librefang/librefang openai driver. +reasoning_echo_policy = "empty_string" aliases = ["kimi", "kimi-k2.6-0420"] [[models]] @@ -34,6 +38,7 @@ supports_tools = true supports_vision = true supports_streaming = true supports_thinking = true +reasoning_echo_policy = "empty_string" aliases = ["kimi-k2.5-0711"] [[models]] @@ -47,4 +52,5 @@ output_cost_per_m = 2.3 supports_tools = true supports_vision = true supports_streaming = true +reasoning_echo_policy = "empty_string" aliases = [] diff --git a/providers/novita.toml b/providers/novita.toml index 63a50a9..455f8a7 100644 --- a/providers/novita.toml +++ b/providers/novita.toml @@ -36,6 +36,7 @@ supports_tools = true supports_vision = false supports_streaming = true supports_thinking = true +reasoning_echo_policy = "empty_string" aliases = [] [[models]] diff --git a/schema.toml b/schema.toml index 14199fd..accce8b 100644 --- a/schema.toml +++ b/schema.toml @@ -152,6 +152,13 @@ required = false description = "Extended thinking / reasoning support" default = false +[provider.sections.models.fields.reasoning_echo_policy] +type = "string" +required = false +description = "How the OpenAI-compatible driver must handle the reasoning_content field on historical assistant turns when this model is used. 'none' (default) omits the field entirely. 'strip' is required by DeepSeek-R1 / deepseek-reasoner — the API rejects multi-turn requests that carry reasoning_content from previous turns. 'echo' is required by DeepSeek V4 Flash and other thinking-mode-on models — the original thinking text MUST be round-tripped on assistant turns that contain tool_calls, otherwise the API returns 400. 'empty_string' is required by Moonshot / Kimi K2 — the field must be present (empty string) on tool_calls turns, with thinking disabled wire-side." +options = ["none", "strip", "echo", "empty_string"] +default = "none" + [provider.sections.models.fields.aliases] type = "array" required = false diff --git a/scripts/validate.py b/scripts/validate.py index f892b00..b7b05d3 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -35,6 +35,7 @@ VALID_TIERS = {"frontier", "smart", "balanced", "fast", "local"} VALID_MODALITIES = {"text", "image", "audio", "video", "music"} +VALID_REASONING_ECHO_POLICIES = {"none", "strip", "echo", "empty_string"} VALID_HAND_CATEGORIES = { "communication", "content", "data", "development", "devops", "finance", "productivity", "research", "social", @@ -102,6 +103,13 @@ def validate_provider_file(filepath: Path) -> list[str]: if tier is not None and tier not in VALID_TIERS: errors.append(f"{filepath.name}: Model '{label}' invalid tier '{tier}'") + policy = model.get("reasoning_echo_policy") + if policy is not None and policy not in VALID_REASONING_ECHO_POLICIES: + errors.append( + f"{filepath.name}: Model '{label}' invalid reasoning_echo_policy " + f"'{policy}' (valid: {', '.join(sorted(VALID_REASONING_ECHO_POLICIES))})" + ) + for cost_field in ( "input_cost_per_m", "output_cost_per_m",