gateway: a resumed reply to a model that takes no prefill is answered after it, not refused (#1447, Edward4226)
OpenCode Go's Claude models (over Anthropic Messages) refuse a conversation ending with the assistant's message, "This model does not support assistant message prefill. The conversation must end with a user message.", and Volcengine's Agent Plan does too, "The last message cannot be from the assistant for a model that does not support prefill". magpie took every Anthropic upstream to go on from a prefill, so both a client's own resume (Codex sends what it has of a cut reply as the last assistant message) and magpie's own continuation of a cut reply (#853) ended with that refusal. The refusal is about the model, so it is remembered for that provider, model and API in unfit (prefillRefused); the provider's other models, and every upstream that takes a prefill, are unchanged. - A Chat or Responses client's conversation ending with the assistant's message, which those APIs read as a turn already said, is asked again with a user turn after it ("Continue."), the conversation the refusal asks for, and so from then on: translated (forwardTranslated, whole and streamed) and relayed as is (passthrough, Chat and Responses: the Volcengine case). - An Anthropic client's prefill is its own ask and still gets the refusal as the vendor sent it. - magpie's own continuation is not asked of that model again, and the try it refused ends the reply with what cut it, as a reply that can't go on did before #853, not with the refusal of an ask the client never made. No user-turn continuation there: a re-answer spliced onto the part the client has is the garble continuation.go avoids. The thinking-block refusal in the same report (a signed thinking block with no text) is a separate fix, in its own commit. Tests (prefill_test.go), from the report's literal messages: the issue's Responses repro and a streamed Chat request to an Anthropic upstream (TestTrailingAssistantToNoPrefillModel, also checks another model keeps its prefill), Volcengine's refusal relayed on Responses and Chat (TestTrailingAssistantRelayedToNoPrefillModel), a cut reply (TestStreamCutOnNoPrefillModel), an Anthropic client's prefill (TestAnthropicPrefillToNoPrefillModel), userLastBody. With the refusal unrecognised (the old code's behaviour) the first three fail with the report's "UP: Upstream request failed: [invalid_request_error] This model does not support assistant message prefill…" / "…the reply didn't end with what cut it"; with the remembering removed they fail with "calls 4 … asked one again" and "calls = 2 … asked to go on again". Run: gofmt -l (clean), go vet -tags nogui ./internal/gateway on darwin, GOOS=linux and GOOS=windows, go test -tags nogui ./... under a scratch HOME with pinned caches (all ok). Not run for real: the owner's OpenCode Go key answers 403 "An active OpenCode Go subscription is required", and there is no Volcengine Agent Plan account.
This commit is contained in:
1 parent
8c21a41846
commit
af1e477d83
6 files changed
+427
-1
No files matched your search
@@ -65,6 +65,7 @@ served by a plugin are in [Provider and plugin ownership](provider-plugins.md).
|
|||||||
- Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671).
|
- Group members are `provider/model[:effort]`. A member with a fixed effort is asked for that effort, bounded by the group's levels (#671).
|
||||||
- An agent whose config can't carry an effort has one picked in magpie (`provider.AgentEffort`, settings' `agentEfforts`; Cursor Private Inference's row, #1003). It is asked for on that agent's requests to a model that reasons (`Provider.Thinks`), in place of the level the request sent, fitted to the model's levels and bounded by the group's. Order: a member's fixed effort, then a model's `:effort` suffix, then the turn's pick (a routing rule's), then the agent's pick, then the request's own. A model that doesn't reason is sent as asked; the default (none picked) leaves every request as it came. The pick beats the request's own because Cursor sends its own default as often as a real choice. Picking Default hands the choice back to Cursor.
|
- An agent whose config can't carry an effort has one picked in magpie (`provider.AgentEffort`, settings' `agentEfforts`; Cursor Private Inference's row, #1003). It is asked for on that agent's requests to a model that reasons (`Provider.Thinks`), in place of the level the request sent, fitted to the model's levels and bounded by the group's. Order: a member's fixed effort, then a model's `:effort` suffix, then the turn's pick (a routing rule's), then the agent's pick, then the request's own. A model that doesn't reason is sent as asked; the default (none picked) leaves every request as it came. The pick beats the request's own because Cursor sends its own default as often as a real choice. Picking Default hands the choice back to Cursor.
|
||||||
- Gemini's OpenAI-compatible API (AI Studio's, or a proxy in front of Gemini on this computer or the LAN: `geminiCompat`) is asked for the model's thoughts in `extra_body.google.thinking_config`. A 400 naming it (`refusesThinkingConfig`) marks the provider unfit for the field (`thinking_config`), and it is asked again with `reasoning_effort` alone. Gemini 3 can't stop thinking, so reasoning turned off goes at `minimal`, or at the model's lowest level where its levels are known and have no `minimal` (`geminiOffLevel`). Google's 400 for `minimal` (`geminiMinimalRefused`; gemini-3.8-flash's "Thinking level is unsupported: THINKING_LEVEL_MINIMAL") names thinking too, but isn't the field turned away: the model is marked as refusing reasoning off (`offRefused`) and asked again at its lowest level, and so from then on, still asked for its thoughts. Vertex AI's OpenAI-compatible API marks each streamed chunk of thoughts in the delta's `extra_content.google.thought`, and such a chunk is read as thinking.
|
- Gemini's OpenAI-compatible API (AI Studio's, or a proxy in front of Gemini on this computer or the LAN: `geminiCompat`) is asked for the model's thoughts in `extra_body.google.thinking_config`. A 400 naming it (`refusesThinkingConfig`) marks the provider unfit for the field (`thinking_config`), and it is asked again with `reasoning_effort` alone. Gemini 3 can't stop thinking, so reasoning turned off goes at `minimal`, or at the model's lowest level where its levels are known and have no `minimal` (`geminiOffLevel`). Google's 400 for `minimal` (`geminiMinimalRefused`; gemini-3.8-flash's "Thinking level is unsupported: THINKING_LEVEL_MINIMAL") names thinking too, but isn't the field turned away: the model is marked as refusing reasoning off (`offRefused`) and asked again at its lowest level, and so from then on, still asked for its thoughts. Vertex AI's OpenAI-compatible API marks each streamed chunk of thoughts in the delta's `extra_content.google.thought`, and such a chunk is read as thinking.
|
||||||
|
- A model that refuses a conversation ending with the assistant's message (`refusesPrefill`: OpenCode Go's Claude models over Messages, "This model does not support assistant message prefill"; Volcengine's Agent Plan, "…for a model that does not support prefill") is marked for that provider, model and API (`prefillRefused`, #1447); the provider's other models keep their prefill ([`prefill.go`](../../internal/gateway/prefill.go)). A Chat or Responses client's conversation ending with the assistant's message, which those APIs read as a turn already said (`Request.LastIsTurn`), is asked again with a user turn after it (`continueText`), translated (`forwardTranslated`) or relayed (`passthrough`, `userLastBody`), and so from then on. An Anthropic client's prefill is its own ask and gets the refusal as sent. magpie's own continuation of a cut reply (`continuation.go`) isn't asked of that model again; the try the model refused ends the reply with what cut it, not with the refusal.
|
||||||
- A model's reply limit is published within the window beside it (#1438, `provider.OutputWithin`, `Entry.PublishedOutput`): `/v1/models`' `max_output_tokens` (`modelObject`), Cursor Private Inference's `capabilities.max_output_tokens` (`cursorLocalModel`), Muse Code's `limit.output` (`museModels`), the GUI's model and group rows (`providerInfo`, `providersState`), `magpie model output`, and every agent's file (`agent.maxTokens`, #338). The window and the output can come from different sources: Grok's backend lists grok-4.7's 256000 window, and models.dev fills its 500000 output (`catalog.Decorate` and `Provider.replyLimit` fill an output only where the vendor gives none). An unknown window or output is published as it is. The catalog keeps the unclamped `Entry.Output`, and a group's member is still asked for no more than that (`outputLimit`, `withMaxOutput`), so a request isn't lowered by the clamp. A limit the user set stays in their settings as they set it (`TestModelListsKeepOutputWithinTheWindow`).
|
- A model's reply limit is published within the window beside it (#1438, `provider.OutputWithin`, `Entry.PublishedOutput`): `/v1/models`' `max_output_tokens` (`modelObject`), Cursor Private Inference's `capabilities.max_output_tokens` (`cursorLocalModel`), Muse Code's `limit.output` (`museModels`), the GUI's model and group rows (`providerInfo`, `providersState`), `magpie model output`, and every agent's file (`agent.maxTokens`, #338). The window and the output can come from different sources: Grok's backend lists grok-4.7's 256000 window, and models.dev fills its 500000 output (`catalog.Decorate` and `Provider.replyLimit` fill an output only where the vendor gives none). An unknown window or output is published as it is. The catalog keeps the unclamped `Entry.Output`, and a group's member is still asked for no more than that (`outputLimit`, `withMaxOutput`), so a request isn't lowered by the clamp. A limit the user set stays in their settings as they set it (`TestModelListsKeepOutputWithinTheWindow`).
|
||||||
|
|
||||||
## Remote magpie APIs
|
## Remote magpie APIs
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ package gateway
|
|||||||
// went wrong, so there the reply ends with the error, as it used to.
|
// went wrong, so there the reply ends with the error, as it used to.
|
||||||
// Only a reply no tool call of has begun goes on: a call's arguments
|
// Only a reply no tool call of has begun goes on: a call's arguments
|
||||||
// can't be prefilled, so one begun ends the reply as it used to. A
|
// can't be prefilled, so one begun ends the reply as it used to. A
|
||||||
// refusal isn't asked again.
|
// refusal isn't asked again, and a model that turned the prefill away
|
||||||
|
// isn't asked to go on (prefill.go).
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -247,6 +248,10 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
|
|||||||
sw := newSSEWriter(w)
|
sw := newSSEWriter(w)
|
||||||
enc := encoder(from, sw, request, u)
|
enc := encoder(from, sw, request, u)
|
||||||
cont := &continuation{mode: prefillHow(p, to, model)}
|
cont := &continuation{mode: prefillHow(p, to, model)}
|
||||||
|
if !s.fits(p.ID, prefillRefused(model), to) {
|
||||||
|
// the model turned a prefill away before (prefill.go, #1447)
|
||||||
|
cont.mode = ""
|
||||||
|
}
|
||||||
var failed, failedCode string
|
var failed, failedCode string
|
||||||
var failedStatus int
|
var failedStatus int
|
||||||
var cut, errSent bool
|
var cut, errSent bool
|
||||||
@@ -301,6 +306,9 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
|
|||||||
}
|
}
|
||||||
var serr error
|
var serr error
|
||||||
for again := 0; ; again++ {
|
for again := 0; ; again++ {
|
||||||
|
// what cut the reply, which it ends with if the model turns the
|
||||||
|
// continuation's prefill away
|
||||||
|
cutBy, cutCode, cutErr := failed, failedCode, serr
|
||||||
failed, failedCode, failedStatus, cut, serr = "", "", 0, false, nil
|
failed, failedCode, failedStatus, cut, serr = "", "", 0, false, nil
|
||||||
var held func() // what a textCallSee of this try holds back
|
var held func() // what a textCallSee of this try holds back
|
||||||
req := request
|
req := request
|
||||||
@@ -322,6 +330,13 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov
|
|||||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||||
res.Body.Close()
|
res.Body.Close()
|
||||||
failed, failedStatus = p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b), res.StatusCode
|
failed, failedStatus = p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b), res.StatusCode
|
||||||
|
if cont.resume && refusesPrefill(res.StatusCode, b) {
|
||||||
|
// a model that takes no prefill (forwardTranslated
|
||||||
|
// remembers it): the reply ends with what cut it, as a
|
||||||
|
// reply that can't go on does, not with the refusal of
|
||||||
|
// an ask the client never made (#1447)
|
||||||
|
failed, failedCode, serr = cutBy, cutCode, cutErr
|
||||||
|
}
|
||||||
if wrongEndpoint(res.StatusCode, b) {
|
if wrongEndpoint(res.StatusCode, b) {
|
||||||
failed += wrongAPINote(p, model, res, b)
|
failed += wrongAPINote(p, model, res, b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3134,6 +3134,13 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider.
|
|||||||
if proto != provider.Anthropic {
|
if proto != provider.Anthropic {
|
||||||
body = s.withoutRefused(p.ID, proto, body)
|
body = s.withoutRefused(p.ID, proto, body)
|
||||||
}
|
}
|
||||||
|
// a conversation the client ended with the assistant's message, to a
|
||||||
|
// model that took no prefill before, is asked with a user turn after
|
||||||
|
// it (prefill.go, #1447)
|
||||||
|
turnAfter := (proto == provider.Chat || proto == provider.Responses) && !s.fits(p.ID, prefillRefused(model), proto)
|
||||||
|
if turnAfter {
|
||||||
|
body, _ = userLastBody(proto == provider.Chat, body)
|
||||||
|
}
|
||||||
// Codex's image tool, which a vendor that knows no namespaces turned
|
// Codex's image tool, which a vendor that knows no namespaces turned
|
||||||
// away before (#949)
|
// away before (#949)
|
||||||
dropImage := proto == provider.Responses && mayDropImageTool(p)
|
dropImage := proto == provider.Responses && mayDropImageTool(p)
|
||||||
@@ -3171,6 +3178,21 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider.
|
|||||||
if proto == provider.Chat {
|
if proto == provider.Chat {
|
||||||
ms = refusedInMessages(res.StatusCode, b, body)
|
ms = refusedInMessages(res.StatusCode, b, body)
|
||||||
}
|
}
|
||||||
|
if len(fs) == 0 && len(ms) == 0 && !turnAfter && refusesPrefill(res.StatusCode, b) {
|
||||||
|
// the model takes no prefill (#1447: Volcengine's Agent Plan
|
||||||
|
// for Codex's resumed reply): the conversation is asked once
|
||||||
|
// more with a user turn after the assistant's message, and so
|
||||||
|
// from then on
|
||||||
|
if nb, ok := userLastBody(proto == provider.Chat, body); ok {
|
||||||
|
turnAfter = true
|
||||||
|
refused = append(refused, prefillRefused(model))
|
||||||
|
body = nb
|
||||||
|
if res, err = s.forward(r.Context(), p, proto, path, p.Prepare(body), r.Header); err != nil {
|
||||||
|
return writeError(w, proto, 502, p.Name+": "+err.Error()), err.Error(), true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(fs) == 0 && len(ms) == 0 && rootUnionRefusal.Match(b) {
|
if len(fs) == 0 && len(ms) == 0 && rootUnionRefusal.Match(b) {
|
||||||
// a tool's parameters refused for a union at their root
|
// a tool's parameters refused for a union at their root
|
||||||
// (#1271): asked once more with them folded to an object
|
// (#1271): asked once more with them folded to an object
|
||||||
@@ -3621,6 +3643,11 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
|
|||||||
if req.Format != nil && !s.fits(p.ID, formatRefused(model), to) {
|
if req.Format != nil && !s.fits(p.ID, formatRefused(model), to) {
|
||||||
req = req.inSystem()
|
req = req.inSystem()
|
||||||
}
|
}
|
||||||
|
if req.LastIsTurn && !req.Resume && req.endsWithAssistant() && !s.fits(p.ID, prefillRefused(model), to) {
|
||||||
|
// a model that takes no prefill is asked to answer after the
|
||||||
|
// client's last, assistant, message (prefill.go, #1447)
|
||||||
|
req = req.userLast()
|
||||||
|
}
|
||||||
if to == provider.Anthropic && p.IsBedrock() && req.Metadata != nil {
|
if to == provider.Anthropic && p.IsBedrock() && req.Metadata != nil {
|
||||||
// not the plain id Bedrock checks metadata.user_id against (#176)
|
// not the plain id Bedrock checks metadata.user_id against (#176)
|
||||||
r := *req
|
r := *req
|
||||||
@@ -3696,6 +3723,17 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to
|
|||||||
s.markUnfit(p.ID, offRefused(model), to)
|
s.markUnfit(p.ID, offRefused(model), to)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if req.endsWithAssistant() && refusesPrefill(res.StatusCode, b) {
|
||||||
|
// the model takes no prefill (#1447), remembered for it alone.
|
||||||
|
// A conversation the client ended with the assistant's message
|
||||||
|
// is asked again with a user turn after it; magpie's own
|
||||||
|
// continuation of a cut reply isn't (streamTranslated)
|
||||||
|
s.markUnfit(p.ID, prefillRefused(model), to)
|
||||||
|
if req.LastIsTurn && !req.Resume {
|
||||||
|
req = req.userLast()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.GeminiCompat && refusesThinkingConfig(res.StatusCode, b) {
|
if req.GeminiCompat && refusesThinkingConfig(res.StatusCode, b) {
|
||||||
// Gemini's own fields turned away (a proxy that isn't in front
|
// Gemini's own fields turned away (a proxy that isn't in front
|
||||||
// of Google after all, or Google changing them): asked as
|
// of Google after all, or Google changing them): asked as
|
||||||
@@ -4345,6 +4383,7 @@ func parse(proto provider.Protocol, body []byte) (*Request, error) {
|
|||||||
}
|
}
|
||||||
// a call's id may carry Gemini's thought signature (gemini_signature.go)
|
// a call's id may carry Gemini's thought signature (gemini_signature.go)
|
||||||
unsignCalls(req)
|
unsignCalls(req)
|
||||||
|
req.LastIsTurn = proto == provider.Chat || proto == provider.Responses
|
||||||
if req.ToolChoice == "required" && len(req.Tools) == 0 && !req.WebSearch && requiredAllowlist(proto, body) {
|
if req.ToolChoice == "required" && len(req.Tools) == 0 && !req.WebSearch && requiredAllowlist(proto, body) {
|
||||||
return nil, fmt.Errorf("required tool choice has no callable tools after filtering")
|
return nil, fmt.Errorf("required tool choice has no callable tools after filtering")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ type Request struct {
|
|||||||
// part, an assistant message the model goes on from, not a turn
|
// part, an assistant message the model goes on from, not a turn
|
||||||
// answered.
|
// answered.
|
||||||
Resume bool
|
Resume bool
|
||||||
|
// LastIsTurn is set on a request from a client whose API reads a
|
||||||
|
// conversation ending with the assistant's message as a turn already
|
||||||
|
// said, to answer after (OpenAI's Chat and Responses), not one to go
|
||||||
|
// on from (Anthropic's prefill): to a model that takes no prefill it
|
||||||
|
// is asked with a user turn after it (prefill.go).
|
||||||
|
LastIsTurn bool
|
||||||
// Namespaced are the tools a Responses client offered inside a
|
// Namespaced are the tools a Responses client offered inside a
|
||||||
// namespace, by the flat name the model is offered them under.
|
// namespace, by the flat name the model is offered them under.
|
||||||
Namespaced map[string]nsTool
|
Namespaced map[string]nsTool
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A model that won't go on from a conversation ending with the
|
||||||
|
// assistant's message (#1447). OpenCode Go's Claude models refuse one,
|
||||||
|
// "This model does not support assistant message prefill. The
|
||||||
|
// conversation must end with a user message.", as Volcengine's Agent Plan
|
||||||
|
// does, "The last message cannot be from the assistant for a model that
|
||||||
|
// does not support prefill". The same providers' other models take it, so
|
||||||
|
// the refusal is remembered for that model on that provider and API, in
|
||||||
|
// unfit, and nowhere else.
|
||||||
|
//
|
||||||
|
// Such a conversation reaches the upstream two ways. magpie's own
|
||||||
|
// continuation of a reply cut mid-way (continuation.go) is not asked of
|
||||||
|
// that model again: the reply ends with the cut's own error, as it did
|
||||||
|
// before continuations, not with the refusal. A conversation the client
|
||||||
|
// sent ending with the assistant's message, which OpenAI's Chat and
|
||||||
|
// Responses APIs read as a turn already said, not one to go on from
|
||||||
|
// (Request.LastIsTurn), is asked with a user turn after it, as the
|
||||||
|
// refusal says: the conversation the model takes, answered after the
|
||||||
|
// assistant's message as an OpenAI upstream answers it. An Anthropic
|
||||||
|
// client's prefill is the client's own ask, and gets the refusal as
|
||||||
|
// Anthropic itself answers it.
|
||||||
|
|
||||||
|
// prefillRefusal is an upstream's refusal of a conversation ending with
|
||||||
|
// the assistant's message.
|
||||||
|
var prefillRefusal = regexp.MustCompile(`(?i)does not support (?:assistant message )?prefill`)
|
||||||
|
|
||||||
|
// prefillRefused is how unfit remembers a provider's model refusing a
|
||||||
|
// conversation ending with the assistant's message.
|
||||||
|
func prefillRefused(model string) string { return "prefill\x00" + model }
|
||||||
|
|
||||||
|
// refusesPrefill says whether an upstream's answer refuses a prefill.
|
||||||
|
func refusesPrefill(status int, body []byte) bool {
|
||||||
|
return badRequest(status) && prefillRefusal.Match(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// continueText is the user turn asked after a conversation the client
|
||||||
|
// ended with the assistant's message, for a model that takes no prefill.
|
||||||
|
const continueText = "Continue."
|
||||||
|
|
||||||
|
// endsWithAssistant says whether r's conversation ends with the
|
||||||
|
// assistant's message rather than a call it made: what a prefill is.
|
||||||
|
func (r *Request) endsWithAssistant() bool {
|
||||||
|
n := len(r.Messages)
|
||||||
|
if n == 0 || r.Messages[n-1].Role != "assistant" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range r.Messages[n-1].Parts {
|
||||||
|
if p.Kind == ToolCall {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLast is r with a user turn after its last, the assistant's,
|
||||||
|
// message.
|
||||||
|
func (r *Request) userLast() *Request {
|
||||||
|
q := *r
|
||||||
|
q.Messages = append(q.Messages[:len(q.Messages):len(q.Messages)], Message{Role: "user", Parts: []Part{{Kind: Text, Text: continueText}}})
|
||||||
|
return &q
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLastBody is a Chat or Responses request relayed as it is, with a
|
||||||
|
// user turn after its last message when that is the assistant's; false
|
||||||
|
// when it isn't.
|
||||||
|
func userLastBody(chat bool, body []byte) ([]byte, bool) {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(body, &m) != nil {
|
||||||
|
return body, false
|
||||||
|
}
|
||||||
|
field := "input"
|
||||||
|
if chat {
|
||||||
|
field = "messages"
|
||||||
|
}
|
||||||
|
var items []json.RawMessage
|
||||||
|
if json.Unmarshal(m[field], &items) != nil || len(items) == 0 {
|
||||||
|
return body, false
|
||||||
|
}
|
||||||
|
var last struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
ToolCalls []json.RawMessage `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(items[len(items)-1], &last)
|
||||||
|
if last.Role != "assistant" || last.Type != "" && last.Type != "message" || len(last.ToolCalls) > 0 {
|
||||||
|
return body, false
|
||||||
|
}
|
||||||
|
var turn any = map[string]any{"type": "message", "role": "user", "content": []map[string]string{{"type": "input_text", "text": continueText}}}
|
||||||
|
if chat {
|
||||||
|
turn = map[string]string{"role": "user", "content": continueText}
|
||||||
|
}
|
||||||
|
t, _ := json.Marshal(turn)
|
||||||
|
m[field], _ = json.Marshal(append(items, t))
|
||||||
|
out, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body, false
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yetone/magpie/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The refusals of #1447: OpenCode Go's Claude models over Anthropic
|
||||||
|
// Messages, and Volcengine's Agent Plan over Responses and Chat. The
|
||||||
|
// messages are the report's, word for word; the envelopes are each
|
||||||
|
// vendor's error shape (OpenCode Go's as its 403 for a lapsed plan reads).
|
||||||
|
const (
|
||||||
|
openCodeGoPrefillRefusal = `{"type":"error","error":{"type":"api_error","message":"Upstream request failed: [invalid_request_error] This model does not support assistant message prefill. The conversation must end with a user message."}}`
|
||||||
|
volcenginePrefillRefusal = `{"error":{"code":"InvalidParameter","message":"The parameter input[194].role specified in the request are not valid: The last message cannot be from the assistant for a model that does not support prefill","param":"input[194].role","type":"BadRequest"}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// noPrefill is an upstream whose model refuses a conversation ending with
|
||||||
|
// the assistant's message, as #1447's do, and answers "OK" to one ending
|
||||||
|
// with the user's. proto is the API it speaks; cut, when set, severs the
|
||||||
|
// first reply after "hel" (Anthropic only). Bodies journals each call.
|
||||||
|
type noPrefill struct {
|
||||||
|
proto provider.Protocol
|
||||||
|
refuse string
|
||||||
|
cut bool
|
||||||
|
mu sync.Mutex
|
||||||
|
bodies []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *noPrefill) serve(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]any
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(b, &body)
|
||||||
|
f.mu.Lock()
|
||||||
|
call := len(f.bodies)
|
||||||
|
f.bodies = append(f.bodies, body)
|
||||||
|
f.mu.Unlock()
|
||||||
|
field := "messages"
|
||||||
|
if f.proto == provider.Responses {
|
||||||
|
field = "input"
|
||||||
|
}
|
||||||
|
items, _ := body[field].([]any)
|
||||||
|
last, _ := items[len(items)-1].(map[string]any)
|
||||||
|
if last["role"] == "assistant" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(400)
|
||||||
|
io.WriteString(w, f.refuse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch f.proto {
|
||||||
|
case provider.Anthropic:
|
||||||
|
if f.cut && call == 0 {
|
||||||
|
writeStream(w, []string{anthStart("m", 7), anthText("hel")}, true, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeStream(w, []string{anthStart("m", 7), anthText("OK"), anthStop(1)}, false, "")
|
||||||
|
case provider.Chat:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
io.WriteString(w, `{"id":"c1","object":"chat.completion","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1}}`)
|
||||||
|
case provider.Responses:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
io.WriteString(w, `{"id":"resp_1","object":"response","status":"completed","model":"m","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"OK","annotations":[]}]}],"usage":{"input_tokens":9,"output_tokens":1,"total_tokens":10}}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *noPrefill) calls() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.bodies)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastUser is the text of call's last message when it is the user's, ""
|
||||||
|
// when it isn't.
|
||||||
|
func (f *noPrefill) lastUser(call int) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
field := "messages"
|
||||||
|
if f.proto == provider.Responses {
|
||||||
|
field = "input"
|
||||||
|
}
|
||||||
|
items, _ := f.bodies[call][field].([]any)
|
||||||
|
last, _ := items[len(items)-1].(map[string]any)
|
||||||
|
if last["role"] != "user" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := last["content"].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
parts, _ := last["content"].([]any)
|
||||||
|
for _, p := range parts {
|
||||||
|
pm, _ := p.(map[string]any)
|
||||||
|
s, _ := pm["text"].(string)
|
||||||
|
sb.WriteString(s)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefillServer saves a provider speaking only f's API and serves its
|
||||||
|
// requests with one gateway, which remembers what it learns across them.
|
||||||
|
func prefillServer(t *testing.T, f *noPrefill) func(path, body string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
fresh(t)
|
||||||
|
up := httptest.NewServer(http.HandlerFunc(f.serve))
|
||||||
|
t.Cleanup(up.Close)
|
||||||
|
p := provider.Provider{ID: "up", Name: "UP", Key: "k", Models: []string{"m", "other"}}
|
||||||
|
switch f.proto {
|
||||||
|
case provider.Anthropic:
|
||||||
|
p.Anthropic = up.URL
|
||||||
|
case provider.Chat:
|
||||||
|
p.Chat = up.URL + "/v1"
|
||||||
|
case provider.Responses:
|
||||||
|
p.Responses = up.URL + "/v1"
|
||||||
|
}
|
||||||
|
if err := provider.Save(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := New().Handler()
|
||||||
|
return func(path, body string) (int, string) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||||
|
return rec.Code, rec.Body.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #1447's repro: a Responses request ending with the assistant's message,
|
||||||
|
// to a model served over Anthropic Messages that takes no prefill, is
|
||||||
|
// answered after it, with a user turn the model takes, rather than ending
|
||||||
|
// with the refusal; and the next such request goes so at once.
|
||||||
|
func TestTrailingAssistantToNoPrefillModel(t *testing.T) {
|
||||||
|
f := &noPrefill{proto: provider.Anthropic, refuse: openCodeGoPrefillRefusal}
|
||||||
|
post := prefillServer(t, f)
|
||||||
|
ask := `{"model":"up/m","stream":false,"input":[
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"Reply with exactly: OK"}]},
|
||||||
|
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"O"}]}]}`
|
||||||
|
code, body := post("/v1/responses", ask)
|
||||||
|
if code != 200 || !strings.Contains(body, `"OK"`) {
|
||||||
|
t.Fatalf("status %d, want the model's answer: %s", code, body)
|
||||||
|
}
|
||||||
|
if f.calls() != 2 || f.lastUser(1) != continueText {
|
||||||
|
t.Fatalf("calls %d, the retry's last user turn %q: want it asked again with a user turn after the assistant's", f.calls(), f.lastUser(1))
|
||||||
|
}
|
||||||
|
// remembered: asked so at once, streamed and on Chat as well
|
||||||
|
code, body = post("/v1/chat/completions", `{"model":"up/m","stream":true,"messages":[
|
||||||
|
{"role":"user","content":"Reply with exactly: OK"},{"role":"assistant","content":"O"}]}`)
|
||||||
|
if code != 200 || chatTextOf(body) != "OK" {
|
||||||
|
t.Fatalf("status %d, want the model's answer: %s", code, body)
|
||||||
|
}
|
||||||
|
if f.calls() != 3 || f.lastUser(2) != continueText {
|
||||||
|
t.Fatalf("calls %d: the model that refused a prefill was asked one again", f.calls())
|
||||||
|
}
|
||||||
|
// only that model: another of the provider's still gets the
|
||||||
|
// conversation as it was
|
||||||
|
post("/v1/chat/completions", `{"model":"up/other","messages":[
|
||||||
|
{"role":"user","content":"hi"},{"role":"assistant","content":"O"}]}`)
|
||||||
|
if f.lastUser(3) != "" {
|
||||||
|
t.Fatalf("another model of the provider lost its prefill: %v", f.bodies[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Anthropic client's prefill is its own ask: the refusal reaches it as
|
||||||
|
// the vendor said it, and nothing is asked in its place.
|
||||||
|
func TestAnthropicPrefillToNoPrefillModel(t *testing.T) {
|
||||||
|
f := &noPrefill{proto: provider.Anthropic, refuse: openCodeGoPrefillRefusal}
|
||||||
|
post := prefillServer(t, f)
|
||||||
|
code, body := post("/v1/messages", `{"model":"up/m","max_tokens":100,"messages":[
|
||||||
|
{"role":"user","content":"Reply with exactly: OK"},{"role":"assistant","content":"O"}]}`)
|
||||||
|
if code != 400 || !strings.Contains(body, "does not support assistant message prefill") {
|
||||||
|
t.Fatalf("status %d, want the refusal: %s", code, body)
|
||||||
|
}
|
||||||
|
if f.calls() != 1 {
|
||||||
|
t.Fatalf("calls = %d, want the client's own request alone", f.calls())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volcengine's Agent Plan refuses Codex's resumed reply on Responses,
|
||||||
|
// relayed as it is (#1447): asked again with a user turn after it, and so
|
||||||
|
// from then on. Chat's relay likewise.
|
||||||
|
func TestTrailingAssistantRelayedToNoPrefillModel(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
proto provider.Protocol
|
||||||
|
path, body string
|
||||||
|
}{
|
||||||
|
{provider.Responses, "/v1/responses", `{"model":"up/m","input":[
|
||||||
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"Reply with exactly: OK"}]},
|
||||||
|
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"O"}]}]}`},
|
||||||
|
{provider.Chat, "/v1/chat/completions", `{"model":"up/m","messages":[
|
||||||
|
{"role":"user","content":"Reply with exactly: OK"},{"role":"assistant","content":"O"}]}`},
|
||||||
|
} {
|
||||||
|
t.Run(string(c.proto), func(t *testing.T) {
|
||||||
|
f := &noPrefill{proto: c.proto, refuse: volcenginePrefillRefusal}
|
||||||
|
post := prefillServer(t, f)
|
||||||
|
code, body := post(c.path, c.body)
|
||||||
|
if code != 200 || !strings.Contains(body, `"OK"`) {
|
||||||
|
t.Fatalf("status %d, want the model's answer: %s", code, body)
|
||||||
|
}
|
||||||
|
if f.calls() != 2 || f.lastUser(1) != continueText {
|
||||||
|
t.Fatalf("calls %d, retry's last user turn %q", f.calls(), f.lastUser(1))
|
||||||
|
}
|
||||||
|
if code, _ := post(c.path, c.body); code != 200 || f.calls() != 3 || f.lastUser(2) != continueText {
|
||||||
|
t.Fatalf("status %d, calls %d: the refusal wasn't remembered", code, f.calls())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// magpie's own continuation of a cut reply, to a model that takes no
|
||||||
|
// prefill, ends with what cut it, not with the refusal of an ask the
|
||||||
|
// client never made; the next cut on that model isn't asked to go on.
|
||||||
|
func TestStreamCutOnNoPrefillModel(t *testing.T) {
|
||||||
|
f := &noPrefill{proto: provider.Anthropic, refuse: openCodeGoPrefillRefusal, cut: true}
|
||||||
|
post := prefillServer(t, f)
|
||||||
|
ask := `{"model":"up/m","stream":true,"messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
code, body := post("/v1/chat/completions", ask)
|
||||||
|
if code != 200 || f.calls() != 2 {
|
||||||
|
t.Fatalf("status %d, calls %d, want the cut asked to go on once: %s", code, f.calls(), body)
|
||||||
|
}
|
||||||
|
if got := chatTextOf(body); got != "hel" {
|
||||||
|
t.Fatalf("client's text = %q, want %q", got, "hel")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "prefill") || !strings.Contains(body, "UP") {
|
||||||
|
t.Fatalf("the reply didn't end with what cut it: %s", body)
|
||||||
|
}
|
||||||
|
f.cut, f.bodies = true, nil
|
||||||
|
post("/v1/chat/completions", ask)
|
||||||
|
if f.calls() != 1 {
|
||||||
|
t.Fatalf("calls = %d: a model that refused the prefill was asked to go on again", f.calls())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userLastBody adds the user turn only after the assistant's message, not
|
||||||
|
// after a call or a tool's output.
|
||||||
|
func TestUserLastBody(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
chat bool
|
||||||
|
body string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{true, `{"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"}]}`, true},
|
||||||
|
{true, `{"messages":[{"role":"user","content":"a"}]}`, false},
|
||||||
|
{true, `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"c"}]}]}`, false},
|
||||||
|
{false, `{"input":[{"role":"assistant","content":"b"}]}`, true},
|
||||||
|
{false, `{"input":[{"type":"function_call","call_id":"c","name":"x","arguments":"{}"}]}`, false},
|
||||||
|
{false, `{"input":"hi"}`, false},
|
||||||
|
} {
|
||||||
|
out, ok := userLastBody(c.chat, []byte(c.body))
|
||||||
|
if ok != c.want {
|
||||||
|
t.Errorf("userLastBody(%s) = %v, want %v", c.body, ok, c.want)
|
||||||
|
}
|
||||||
|
if ok && !strings.Contains(string(out), continueText) {
|
||||||
|
t.Errorf("userLastBody(%s) = %s, no user turn", c.body, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user