mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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{} {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user