diff --git a/docs/subsystems/gateway-routing.md b/docs/subsystems/gateway-routing.md index 6836d4f7..251b9d75 100644 --- a/docs/subsystems/gateway-routing.md +++ b/docs/subsystems/gateway-routing.md @@ -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). - 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. +- 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`). ## Remote magpie APIs diff --git a/internal/gateway/continuation.go b/internal/gateway/continuation.go index 17caf1ce..06cdee17 100644 --- a/internal/gateway/continuation.go +++ b/internal/gateway/continuation.go @@ -13,7 +13,8 @@ package gateway // 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 // 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 ( "context" @@ -247,6 +248,10 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov sw := newSSEWriter(w) enc := encoder(from, sw, request, u) 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 failedStatus int var cut, errSent bool @@ -301,6 +306,9 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov } var serr error 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 var held func() // what a textCallSee of this try holds back 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)) res.Body.Close() 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) { failed += wrongAPINote(p, model, res, b) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index e2d24cac..97a685f7 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -3134,6 +3134,13 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider. if proto != provider.Anthropic { 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 // away before (#949) 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 { 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) { // a tool's parameters refused for a union at their root // (#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) { 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 { // not the plain id Bedrock checks metadata.user_id against (#176) r := *req @@ -3696,6 +3723,17 @@ func (s *Server) forwardTranslated(ctx context.Context, p provider.Provider, to s.markUnfit(p.ID, offRefused(model), to) 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) { // Gemini's own fields turned away (a proxy that isn't in front // 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) unsignCalls(req) + req.LastIsTurn = proto == provider.Chat || proto == provider.Responses 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") } diff --git a/internal/gateway/ir.go b/internal/gateway/ir.go index 34456501..de9ce286 100644 --- a/internal/gateway/ir.go +++ b/internal/gateway/ir.go @@ -190,6 +190,12 @@ type Request struct { // part, an assistant message the model goes on from, not a turn // answered. 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 // namespace, by the flat name the model is offered them under. Namespaced map[string]nsTool diff --git a/internal/gateway/prefill.go b/internal/gateway/prefill.go new file mode 100644 index 00000000..c9f51260 --- /dev/null +++ b/internal/gateway/prefill.go @@ -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 +} diff --git a/internal/gateway/prefill_test.go b/internal/gateway/prefill_test.go new file mode 100644 index 00000000..6f88b8cd --- /dev/null +++ b/internal/gateway/prefill_test.go @@ -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) + } + } +}