From 46145df9255db5807730f26b57638a0d496ff198 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 00:04:13 +0100 Subject: [PATCH] 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. --- .../v6/internal/subsystems/ai-runtime.md | 15 ++++++++------ internal/ai/providers/openai.go | 16 +++++---------- internal/ai/providers/openai_test.go | 20 ++++++++++--------- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index b2861dcc9..11223231e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index 98ddf35e9..1ce2d773d 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -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{} { diff --git a/internal/ai/providers/openai_test.go b/internal/ai/providers/openai_test.go index 288a4ec74..bb1de9bd1 100644 --- a/internal/ai/providers/openai_test.go +++ b/internal/ai/providers/openai_test.go @@ -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")