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
@@ -108,7 +108,7 @@ runtime cost control, and shared AI transport surfaces.
## Completion Obligations ## 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` 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 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, 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. of dropping the last chunk or leaving tool calls unfinalized on clean close.
That same provider-transport boundary owns OpenAI-compatible tool protocol That same provider-transport boundary owns OpenAI-compatible tool protocol
adaptation. For direct DeepSeek provider paths, the shared OpenAI-compatible adaptation. For direct DeepSeek provider paths, the shared OpenAI-compatible
client must preserve specific or required `tool_choice` values for current client must coerce specific or required `tool_choice` values to provider-
DeepSeek V4 tool-capable models and the legacy aliases that currently route to supported auto tool selection for every DeepSeek model ID, including current
that V4 contract. Unknown direct DeepSeek model IDs must still degrade offered V4 tool-capable models, legacy aliases, and unknown direct DeepSeek IDs.
tool requests to provider-supported auto tool selection so provider errors DeepSeek's API server-side aliases V4 IDs to `deepseek-reasoner`, which
remain model/readiness diagnostics instead of forced-tool protocol noise. 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` Reasoning-backed provider turns that return tool calls with `reasoning_content`
must preserve that reasoning state on the following tool-result turn when the 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 provider requires it, so Assistant and Patrol can complete multi-turn tool use
+5 -11
View File
@@ -12,7 +12,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -182,16 +181,11 @@ func (c *OpenAIClient) shouldSendReasoningContent() bool {
} }
func (c *OpenAIClient) supportsForcedToolChoice(model string) bool { func (c *OpenAIClient) supportsForcedToolChoice(model string) bool {
if !c.isDeepSeek() { // DeepSeek's API server-side aliases v4-flash/v4-pro to deepseek-reasoner,
return true // which rejects forced tool_choice with HTTP 400. Always coerce to "auto"
} // for any DeepSeek model so Patrol stays functional regardless of how
normalized := normalizeOpenAICompatibleModelName(model) // DeepSeek routes the requested id.
switch { return !c.isDeepSeek()
case config.IsDeepSeekV4Model(normalized), config.IsDeepSeekLegacyAliasModel(normalized):
return true
default:
return false
}
} }
func (c *OpenAIClient) toolChoiceForModel(model string, choice *ToolChoice) interface{} { func (c *OpenAIClient) toolChoiceForModel(model string, choice *ToolChoice) interface{} {
+11 -9
View File
@@ -400,17 +400,16 @@ func TestOpenAIClient_ChatStream_ToolChoiceNone_DropsTools(t *testing.T) {
assert.True(t, doneCalled) 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{} var got map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "deepseek-v4-flash", got["model"]) assert.Equal(t, "deepseek-v4-flash", got["model"])
assert.Equal(t, map[string]interface{}{ assert.Equal(t, "auto", got["tool_choice"])
"type": "function",
"function": map[string]interface{}{
"name": "ping",
},
}, got["tool_choice"])
require.Len(t, got["tools"], 1) require.Len(t, got["tools"], 1)
_ = json.NewEncoder(w).Encode(openaiResponse{ _ = json.NewEncoder(w).Encode(openaiResponse{
@@ -439,12 +438,15 @@ func TestOpenAIClient_Chat_DeepSeekV4PreservesForcedToolChoice(t *testing.T) {
require.NoError(t, err) 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) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var got map[string]interface{} var got map[string]interface{}
require.NoError(t, json.NewDecoder(r.Body).Decode(&got)) require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "deepseek-v4-flash", got["model"]) 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) require.Len(t, got["tools"], 1)
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")