feat(providers): add reasoning_echo_policy field for OpenAI-compat reasoning_content handling (#90)

Refs librefang/librefang#4842 — long-term replacement for the substring
match that the OpenAI driver currently uses to decide how to handle
`reasoning_content` on historical assistant turns.

Three provider-specific behaviours that the driver must distinguish at
wire time, now expressed as catalog metadata:

* `strip`  — DeepSeek R1 / deepseek-reasoner. The API rejects requests
  that carry reasoning_content on previous assistant messages.
* `echo`   — DeepSeek V4 Flash. Thinking mode is on by default and the
  API rejects multi-turn requests when assistant turns containing
  tool_calls don't echo back the original reasoning text. This is the
  bug surfaced in librefang/librefang#4842.
* `empty_string` — Moonshot / Kimi K2 family. The field must be present
  (empty string) on tool_calls turns, with thinking disabled wire-side
  for multi-turn compatibility.
* `none` (default) — most providers; field is omitted entirely.

V4 Pro is intentionally NOT marked `echo` — librefang#4842 reports it
working out-of-the-box; flip when there's an empirical reproducer.

Marks affected models:

  providers/deepseek.toml
    deepseek-v4-flash → echo
    deepseek-reasoner → strip
  providers/moonshot.toml
    kimi-k2.6, kimi-k2.5, kimi-k2 → empty_string
  providers/kimi-coding.toml
    kimi-for-coding → empty_string
  providers/byteplus-coding.toml
    kimi-k2.5 → empty_string
  providers/novita.toml
    moonshotai/kimi-k2-thinking → empty_string

Tooling:

* schema.toml registers the field with the four enum options and a
  `none` default so existing TOML files keep parsing unchanged.
* scripts/validate.py rejects unknown enum values; verified with a
  hand-crafted negative case (`reasoning_echo_policy = "bogus"` →
  validation fails with the expected message).
* `python3 scripts/validate.py` passes (267 models).

The librefang side that consumes this field will land in a follow-up
PR — until then, registry consumers ignore the field via
`#[serde(default)]` and the existing substring fallback continues to
work, so this commit is safe to ship independently.
This commit is contained in:
Evan authored and GitHub committed 2026-05-11 00:41:24 +09:00
1 parent 3e0575ebcb
commit 6785807633
7 files changed
+31

No files matched your search

+1
View File
@@ -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]]
+7
View File
@@ -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"]
+1
View File
@@ -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 = []
+6
View File
@@ -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 = []
+1
View File
@@ -36,6 +36,7 @@ supports_tools = true
supports_vision = false
supports_streaming = true
supports_thinking = true
reasoning_echo_policy = "empty_string"
aliases = []
[[models]]
+7
View File
@@ -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
+8
View File
@@ -35,6 +35,7 @@ except ImportError:
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",