mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +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:
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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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{} {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue