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")