fix(llm): structured output for DeepSeek V4 and reasoner
DeepSeek V4 and reasoner reject tool_choice but accept tools. Route via a per-model capability table that suppresses tool_choice for thinking-mode models. #678 #689
This commit is contained in:
1 parent
afdc6d4ec1
commit
22bb91bd83
4 files changed
+306
-57
No files matched your search
@@ -0,0 +1,95 @@
|
||||
"""Declarative per-model capability table for OpenAI-compatible providers.
|
||||
|
||||
This is the single place that knows which model IDs reject which API
|
||||
parameters or require which structured-output method. The LLM client
|
||||
subclasses consult ``get_capabilities(model_name)`` instead of hardcoding
|
||||
model-name ``if`` ladders, so adding a new model (or a new provider quirk)
|
||||
means editing this table — not the client code.
|
||||
|
||||
Pattern adapted from the per-model ``compat:`` flags DeepSeek themselves
|
||||
publish in their integration guides (e.g. the Oh My Pi config schema
|
||||
documents ``supportsToolChoice``, ``requiresReasoningContentForToolCalls``
|
||||
as declarative per-model fields).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
StructuredMethod = Literal[
|
||||
"function_calling", # uses tools; respects supports_tool_choice
|
||||
"json_mode", # uses response_format={"type":"json_object"}
|
||||
"json_schema", # uses response_format={"type":"json_schema",...}
|
||||
"none", # no structured output available; caller falls back to free-text
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelCapabilities:
|
||||
"""What an OpenAI-compatible model accepts at the API level."""
|
||||
|
||||
supports_tool_choice: bool
|
||||
supports_json_mode: bool
|
||||
supports_json_schema: bool
|
||||
preferred_structured_method: StructuredMethod
|
||||
# DeepSeek thinking-mode models 400 if reasoning_content from prior
|
||||
# assistant turns is not echoed back on the next request.
|
||||
requires_reasoning_content_roundtrip: bool = False
|
||||
|
||||
|
||||
# DeepSeek's thinking models accept the ``tools`` array but reject the
|
||||
# ``tool_choice`` parameter (official Oh My Pi integration guide and the
|
||||
# 400 response in issue #678). Their official tool-calling examples
|
||||
# (api-docs.deepseek.com/guides/tool_calls) pass ``tools=[...]`` without
|
||||
# ``tool_choice`` — we mirror that pattern by setting supports_tool_choice
|
||||
# to False and letting the client suppress the kwarg.
|
||||
_DEEPSEEK_THINKING = ModelCapabilities(
|
||||
supports_tool_choice=False,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
requires_reasoning_content_roundtrip=True,
|
||||
)
|
||||
|
||||
_DEEPSEEK_CHAT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=False,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
_DEFAULT = ModelCapabilities(
|
||||
supports_tool_choice=True,
|
||||
supports_json_mode=True,
|
||||
supports_json_schema=True,
|
||||
preferred_structured_method="function_calling",
|
||||
)
|
||||
|
||||
|
||||
# Exact-ID matches take precedence over pattern matches.
|
||||
_BY_ID: dict[str, ModelCapabilities] = {
|
||||
"deepseek-chat": _DEEPSEEK_CHAT,
|
||||
"deepseek-reasoner": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-flash": _DEEPSEEK_THINKING,
|
||||
"deepseek-v4-pro": _DEEPSEEK_THINKING,
|
||||
}
|
||||
|
||||
# Forward-compat patterns. A new ``deepseek-v5-*`` or ``deepseek-reasoner-*``
|
||||
# variant inherits the thinking-mode quirks automatically.
|
||||
_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [
|
||||
(re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING),
|
||||
(re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING),
|
||||
]
|
||||
|
||||
|
||||
def get_capabilities(model_name: str) -> ModelCapabilities:
|
||||
"""Resolve capabilities by exact ID, then pattern, then default."""
|
||||
if model_name in _BY_ID:
|
||||
return _BY_ID[model_name]
|
||||
for pattern, caps in _BY_PATTERN:
|
||||
if pattern.match(model_name):
|
||||
return caps
|
||||
return _DEFAULT
|
||||
Reference in new issue
Block a user