Coerce DeepSeek tool_choice to "auto" so Patrol stops failing

DeepSeek's API server-side aliases deepseek-v4-flash and
deepseek-v4-pro to deepseek-reasoner, which rejects forced
tool_choice with HTTP 400 ("deepseek-reasoner does not support
this tool_choice"). Pulse's classifier then surfaced this as
"Selected model does not support Patrol tools," misdirecting
diagnosis to the model rather than the request shape.

supportsForcedToolChoice now returns false for any DeepSeek
client, so every DeepSeek model falls back to tool_choice
"auto" regardless of how DeepSeek routes the requested ID.
The ai-runtime contract is updated to match: the
provider-transport boundary now coerces forced tool_choice for
every direct DeepSeek model ID, not only unknown ones.

Patrol verified end-to-end: 20 tool calls, 9 findings, prior
runtime failure auto-resolved.
This commit is contained in:
rcourtman 2026-05-10 00:04:13 +01:00
parent 6eb5bca06b
commit 46145df925
3 changed files with 25 additions and 26 deletions

View file

@ -108,7 +108,7 @@ runtime cost control, and shared AI transport surfaces.
## Completion Obligations
1. Update this contract when canonical AI runtime or transport entry points move
1. Update this contract when canonical AI runtime or transport entry points move, including transport-level provider request-shape changes such as DeepSeek `tool_choice` coercion
2. Keep AI runtime and shared API proof routing aligned in `registry.json`
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes
Patrol runtime failures are part of that runtime contract: provider, model,
@ -1625,11 +1625,14 @@ event emission through the same canonical finalizer used for `[DONE]` instead
of dropping the last chunk or leaving tool calls unfinalized on clean close.
That same provider-transport boundary owns OpenAI-compatible tool protocol
adaptation. For direct DeepSeek provider paths, the shared OpenAI-compatible
client must preserve specific or required `tool_choice` values for current
DeepSeek V4 tool-capable models and the legacy aliases that currently route to
that V4 contract. Unknown direct DeepSeek model IDs must still degrade offered
tool requests to provider-supported auto tool selection so provider errors
remain model/readiness diagnostics instead of forced-tool protocol noise.
client must coerce specific or required `tool_choice` values to provider-
supported auto tool selection for every DeepSeek model ID, including current
V4 tool-capable models, legacy aliases, and unknown direct DeepSeek IDs.
DeepSeek's API server-side aliases V4 IDs to `deepseek-reasoner`, which
rejects forced `tool_choice` with HTTP 400, so coercing to auto for all
direct DeepSeek paths keeps Patrol functional regardless of how DeepSeek
routes the requested ID and keeps any provider errors as model or readiness
diagnostics instead of forced-tool protocol noise.
Reasoning-backed provider turns that return tool calls with `reasoning_content`
must preserve that reasoning state on the following tool-result turn when the
provider requires it, so Assistant and Patrol can complete multi-turn tool use

View file

@ -12,7 +12,6 @@ import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
@ -182,16 +181,11 @@ func (c *OpenAIClient) shouldSendReasoningContent() bool {
}
func (c *OpenAIClient) supportsForcedToolChoice(model string) bool {
if !c.isDeepSeek() {
return true
}
normalized := normalizeOpenAICompatibleModelName(model)
switch {
case config.IsDeepSeekV4Model(normalized), config.IsDeepSeekLegacyAliasModel(normalized):
return true
default:
return false
}
// DeepSeek's API server-side aliases v4-flash/v4-pro to deepseek-reasoner,
// which rejects forced tool_choice with HTTP 400. Always coerce to "auto"
// for any DeepSeek model so Patrol stays functional regardless of how
// DeepSeek routes the requested id.
return !c.isDeepSeek()
}
func (c *OpenAIClient) toolChoiceForModel(model string, choice *ToolChoice) interface{} {

View file

@ -400,17 +400,16 @@ func TestOpenAIClient_ChatStream_ToolChoiceNone_DropsTools(t *testing.T) {
assert.True(t, doneCalled)
}
func TestOpenAIClient_Chat_DeepSeekV4PreservesForcedToolChoice(t *testing.T) {
func TestOpenAIClient_Chat_DeepSeekCoercesForcedToolChoiceToAuto(t *testing.T) {
// DeepSeek's API server-side aliases v4-flash/v4-pro to deepseek-reasoner,
// which rejects forced tool_choice with HTTP 400. Pulse coerces any DeepSeek
// forced tool_choice to "auto" so Patrol stays functional regardless of how
// DeepSeek routes the requested id.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "deepseek-v4-flash", got["model"])
assert.Equal(t, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "ping",
},
}, got["tool_choice"])
assert.Equal(t, "auto", got["tool_choice"])
require.Len(t, got["tools"], 1)
_ = json.NewEncoder(w).Encode(openaiResponse{
@ -439,12 +438,15 @@ func TestOpenAIClient_Chat_DeepSeekV4PreservesForcedToolChoice(t *testing.T) {
require.NoError(t, err)
}
func TestOpenAIClient_ChatStream_DeepSeekV4PreservesRequiredToolChoice(t *testing.T) {
func TestOpenAIClient_ChatStream_DeepSeekCoercesAnyToolChoiceToAuto(t *testing.T) {
// DeepSeek's API server-side aliases v4-flash/v4-pro to deepseek-reasoner,
// which rejects forced tool_choice with HTTP 400. Pulse coerces any DeepSeek
// forced tool_choice to "auto" on streaming requests as well.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "deepseek-v4-flash", got["model"])
assert.Equal(t, "required", got["tool_choice"])
assert.Equal(t, "auto", got["tool_choice"])
require.Len(t, got["tools"], 1)
w.Header().Set("Content-Type", "text/event-stream")