The IR held a schema only from Anthropic's output_config.format, and only Messages (and Claude Code's --json-schema) were asked for one. A Responses request to a provider magpie speaks Chat or Messages to (OpenRouter has no Responses URL) lost text.format; a Chat request translated to Responses or Messages lost response_format; an Anthropic schema translated to Chat or Responses was lost too. Format (format.go) now carries json_schema (name, description, schema, strict) or json_object from Responses, Chat, Anthropic and Gemini, and each builder asks for it in its upstream's field. Where a target can't say it (json_object on Messages or Claude Code; the vendor bridges Cursor, Devin, Kiro, Qoder, Command Code, Zed) it is told in the system prompt. An upstream answering 400/422 naming the field (DeepSeek's "This response_format type is unavailable now") is asked again with it told in words, and so from then on. Relayed requests are unchanged.
707 lines
21 KiB
Go
707 lines
21 KiB
Go
package gateway
|
|
|
|
// PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn")
|
|
// are deprecated built-in subscriptions served by their plugin,
|
|
// @magpie-community/opencode-qoder-auth, each once moved onto it
|
|
// (provider.Moved; the default for a new sign-in). A moved one's
|
|
// sign-ins, models, requests and usage are all the plugin's, never this
|
|
// code's (only the move, in migrate*.go, still reads its accounts). A fix here alone doesn't reach those users; fix the plugin
|
|
// (github.com/magpie-community/plugins, packages/qoder) and raise the
|
|
// movers' min in internal/provider/migrate_qoder.go.
|
|
|
|
import (
|
|
"bufio"
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
"github.com/yetone/magpie/internal/qoder"
|
|
)
|
|
|
|
// A Qoder subscription is served through the API the Qoder client talks to
|
|
// (api3.qoder.sh agent_chat_generation SSE), signed with the COSY envelope and
|
|
// encoded with the client's body codec, the way a Devin one is (devin.go).
|
|
// Qoder exposes tool calls as XML in its text, so the request is given the
|
|
// matching contract and the reply is adapted back into tool-call events.
|
|
// The protocol lives in internal/qoder.
|
|
//
|
|
// The XML markers below are written with hex escapes for their angle brackets
|
|
// so this source holds no literal tag the tooling would mistake for a call.
|
|
const (
|
|
qoderCallOpen = "\x3ctool_call\x3e"
|
|
qoderCallClose = "\x3c/tool_call\x3e"
|
|
)
|
|
|
|
var (
|
|
qoderFunc = regexp.MustCompile(`(?s)\x3cfunction=([^>]+)\x3e(.*?)\x3c/function\x3e`)
|
|
qoderArg = regexp.MustCompile(`(?s)\x3cparameter=([^>]+)\x3e(.*?)\x3c/parameter\x3e`)
|
|
)
|
|
|
|
const qoderSys = "You are a Qoder agent. Use the instructions below and the tools available to you to assist the user."
|
|
|
|
// qoderAuth hands a request the signed-in account's uid and a live job
|
|
// token, and qoderURL the chat endpoint on the credential's site (qoder.com's
|
|
// or Qoder CN's); vars so tests can stand in for them, the way a Devin round
|
|
// stands in for devinAuth.
|
|
var (
|
|
qoderAuth = provider.QoderCredentialOf
|
|
qoderModel = provider.QoderModelOf
|
|
qoderURL = func(c *qoder.Credential) string { return c.OnSite().ChatURL() }
|
|
)
|
|
|
|
// serveQoder answers a request through Qoder's API.
|
|
func (s *Server) serveQoder(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, usage *Usage) (int, string) {
|
|
req, err := parse(from, body)
|
|
if err != nil {
|
|
return writeError(w, from, 400, err.Error()), err.Error()
|
|
}
|
|
// the vendor's own API is told the answer's format in words
|
|
req = req.inSystem()
|
|
req.Model = model
|
|
ask := s.askQoder(p.Account.Agent, model, p.Account.User)
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
q := req
|
|
var tool string
|
|
if req.WebSearch && !searching(r.Context()) {
|
|
if canSearch() {
|
|
copy := *req
|
|
copy.WebSearch = false
|
|
search := searchTool(copy.Tools)
|
|
copy.Tools = append(slices.Clone(copy.Tools), search)
|
|
copy.Messages = slices.Clone(copy.Messages)
|
|
q, tool = ©, search.Name
|
|
}
|
|
}
|
|
events, status, msg := ask(ctx, q)
|
|
if events == nil {
|
|
return writeError(w, from, status, msg), msg
|
|
}
|
|
if tool != "" {
|
|
first := events
|
|
out := make(chan Event, 16)
|
|
go func() {
|
|
defer close(out)
|
|
s.searchRounds(ctx, q, tool, first, ask, out)
|
|
}()
|
|
events = out
|
|
}
|
|
return relayQoder(w, from, req, events, usage, cancel)
|
|
}
|
|
|
|
// relayQoder preserves upstream failures before committing a response, including
|
|
// failures after partial non-streaming text. Streaming failures after output
|
|
// has begun are terminal error events. Keep this policy to the backends that
|
|
// report their own statuses: Qoder's, and Zed's (relayStatus).
|
|
func relayQoder(w http.ResponseWriter, from provider.Protocol, req *Request, events <-chan Event, usage *Usage, abort context.CancelFunc) (int, string) {
|
|
return relayStatus(w, from, "Qoder", req, events, usage, abort)
|
|
}
|
|
|
|
// relayStatus is relayQoder for the backend called name.
|
|
func relayStatus(w http.ResponseWriter, from provider.Protocol, name string, req *Request, events <-chan Event, usage *Usage, abort context.CancelFunc) (int, string) {
|
|
fail := func(ev Event) (int, string) {
|
|
abort()
|
|
code := ev.Status
|
|
if code < 400 || code > 599 {
|
|
code = 502
|
|
}
|
|
return writeError(w, from, code, ev.Text), ev.Text
|
|
}
|
|
if req.Stream {
|
|
var head []Event
|
|
for ev := range events {
|
|
if ev.Kind == KError {
|
|
return fail(ev)
|
|
}
|
|
head = append(head, ev)
|
|
if ev.Kind != KStart && ev.Kind != KUsage {
|
|
break
|
|
}
|
|
}
|
|
if len(head) == 0 || head[len(head)-1].Kind == KStart || head[len(head)-1].Kind == KUsage {
|
|
return fail(Event{Text: name + " ended without an answer"})
|
|
}
|
|
sw := newSSEWriter(w)
|
|
enc := encoder(from, sw, req, usage)
|
|
var failed string
|
|
see := func(ev Event) bool {
|
|
if ev.Kind == KStart || ev.Kind == KUsage {
|
|
usage.add(ev.Usage)
|
|
}
|
|
enc.event(ev)
|
|
if ev.Kind == KError {
|
|
failed = ev.Text
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
for _, ev := range head {
|
|
see(ev)
|
|
}
|
|
// kept from the client's idle timeout while none comes (#436)
|
|
relayEvents(events, sw, enc, see)
|
|
if failed != "" {
|
|
abort()
|
|
return 200, failed
|
|
}
|
|
enc.finish()
|
|
return 200, ""
|
|
}
|
|
var col collector
|
|
for ev := range events {
|
|
if ev.Kind == KError {
|
|
return fail(ev)
|
|
}
|
|
col.add(ev)
|
|
}
|
|
res := col.finish()
|
|
usage.add(res.Usage)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
w.Write(renderUsage(from, res, req, usage))
|
|
return 200, ""
|
|
}
|
|
|
|
// askQoder is a round for Qoder's API, on the site agent ("qoder" or
|
|
// "qoder-cn") names.
|
|
func (s *Server) askQoder(agent, model, user string) round {
|
|
return func(ctx context.Context, req *Request) (<-chan Event, int, string) {
|
|
cred, err := qoderAuth(ctx, agent, user)
|
|
if err != nil {
|
|
// only a sign-in that is gone asks the agent to log in again;
|
|
// a refresh that timed out or never reached Qoder may pass
|
|
status := 502
|
|
if errors.Is(err, provider.ErrQoderSignIn) {
|
|
status = 401
|
|
}
|
|
return nil, status, "Qoder: " + err.Error()
|
|
}
|
|
config, err := qoderModel(ctx, agent, user, model)
|
|
if err != nil {
|
|
return nil, 400, err.Error()
|
|
}
|
|
plain, err := qoderChatBody(req, config)
|
|
if err != nil {
|
|
return nil, 500, "Qoder: " + err.Error()
|
|
}
|
|
wire := qoder.EncodeRequestBody(plain)
|
|
ts := time.Now().Unix()
|
|
chat := qoderURL(cred)
|
|
headers, err := qoder.BuildCosyHeaders(chat, &qoder.User{UID: cred.UID, Token: cred.Token,
|
|
Name: cred.Name, Email: cred.Email, MachineID: cred.MachineID}, wire, ts)
|
|
if err != nil {
|
|
return nil, 500, "Qoder: " + err.Error()
|
|
}
|
|
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, chat, strings.NewReader(wire))
|
|
if err != nil {
|
|
return nil, 500, "Qoder: " + err.Error()
|
|
}
|
|
for k, v := range headers {
|
|
hr.Header.Set(k, v)
|
|
}
|
|
hr.Header.Set("Accept", "text/event-stream")
|
|
hr.Header.Set("Cache-Control", "no-cache")
|
|
hr.Header.Set("Accept-Encoding", "identity")
|
|
hr.Header.Set("X-Model-Key", config.Key)
|
|
hr.Header.Set("X-Model-Source", config.Source)
|
|
res, err := s.client.Do(hr)
|
|
if err != nil {
|
|
return nil, 502, "Qoder: " + err.Error()
|
|
}
|
|
res = notAnAPIReply(res, "") // a web page served 200 (#1012)
|
|
if res.StatusCode/100 != 2 {
|
|
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
res.Body.Close()
|
|
status, msg := qoderFailure(res.StatusCode, b)
|
|
return nil, status, msg
|
|
}
|
|
events := make(chan Event, 32)
|
|
go decodeQoder(ctx, res, events, model)
|
|
return events, 0, ""
|
|
}
|
|
}
|
|
|
|
// qoderFailure maps a bad upstream status to magpie's status and message.
|
|
func qoderFailure(status int, body []byte) (int, string) {
|
|
if status < 400 || status > 599 {
|
|
status = 502
|
|
}
|
|
msg := strings.TrimSpace(string(body))
|
|
if gjson.ValidBytes(body) {
|
|
if m := gjson.GetBytes(body, "message").String(); m != "" {
|
|
msg = m
|
|
}
|
|
// the model vendor's own reason, e.g. an effort the model refuses
|
|
if d := gjson.Get(gjson.GetBytes(body, "details").String(), "error.message").String(); d != "" {
|
|
msg += ": " + d
|
|
}
|
|
}
|
|
if msg == "" {
|
|
msg = http.StatusText(status)
|
|
}
|
|
switch {
|
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
|
return 401, "Qoder: the sign-in lapsed — sign in again"
|
|
case status == http.StatusTooManyRequests || strings.Contains(strings.ToLower(msg), "quota"):
|
|
return 429, "usage limit reached: " + msg
|
|
}
|
|
return status, "Qoder: " + msg
|
|
}
|
|
|
|
// qoderChatBody is the plaintext JSON Qoder's agent_chat_generation takes,
|
|
// built from magpie's IR request the way the reference does: messages as
|
|
// content blocks, tool calls and results as OpenAI tool turns, and the
|
|
// caller's tools beside them.
|
|
func qoderChatBody(req *Request, model qoder.ModelInfo) ([]byte, error) {
|
|
if model.Key == "" || len(model.Config) == 0 {
|
|
return nil, fmt.Errorf("Qoder: missing model configuration")
|
|
}
|
|
sys := map[string]any{"type": "text", "text": qoderSysText(req)}
|
|
all := []any{map[string]any{"role": "system", "content": []any{sys}}}
|
|
|
|
asked := req.Effort
|
|
if req.ThinkOff {
|
|
asked = "none" // Effort reads none as low
|
|
}
|
|
thinking, effort := qoderEffort(asked, model)
|
|
maxTok := int64(32000)
|
|
if req.MaxTokens > 0 {
|
|
maxTok = int64(req.MaxTokens)
|
|
}
|
|
params := map[string]any{"enable_thinking": thinking, "max_tokens": maxTok}
|
|
if effort != "" {
|
|
params["reasoning_effort"] = effort
|
|
}
|
|
if model.MaxInputTokens > 0 {
|
|
params["context_length"] = model.MaxInputTokens
|
|
}
|
|
body := map[string]any{
|
|
"parameters": params,
|
|
"business": map[string]any{
|
|
"product": "app", "version": "1.1.49", "type": "agent",
|
|
"id": qoder.NewID(), "name": "magpie session",
|
|
"begin_at": time.Now().UnixMilli(), "stage": "start",
|
|
},
|
|
"agent_id": "agent_common",
|
|
"task_id": "common",
|
|
"session_type": "app",
|
|
"model_config": model.Config,
|
|
"system": []any{sys},
|
|
"messages": append(all, qoderMessages(req.Messages)...),
|
|
}
|
|
if req.ToolChoice != "none" && len(req.Tools) > 0 {
|
|
body["tools"] = qoderTools(req.Tools)
|
|
}
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
// qoderMessages renders magpie's turns as OpenAI turns, the format Qoder's
|
|
// chat endpoint takes: an assistant's tool calls as tool_calls and their
|
|
// results as tool turns named by tool_call_id, so a result is paired with
|
|
// its call and not by order. A tool turn holds text only, so the images a
|
|
// tool returned go after the tool turns in a user turn — as the start of
|
|
// the user's own turn when one comes next — the way buildChat sends them.
|
|
func qoderMessages(ms []Message) []any {
|
|
var out []any
|
|
names := map[string]string{}
|
|
var seen []any
|
|
seeLater := func(p Part) int {
|
|
var ims []any
|
|
for _, im := range p.Images {
|
|
if im.Data != "" {
|
|
ims = append(ims, map[string]any{"type": "image_url",
|
|
"image_url": map[string]any{"url": "data:" + im.MediaType + ";base64," + im.Data}})
|
|
}
|
|
}
|
|
if len(ims) == 0 {
|
|
return 0
|
|
}
|
|
of := "tool call " + p.CallID
|
|
if name := cmp.Or(names[p.CallID], p.Name); name != "" {
|
|
of = name + " (" + of + ")"
|
|
}
|
|
seen = append(seen, map[string]any{"type": "text", "text": "[From the result of " + of + ":]"})
|
|
seen = append(seen, ims...)
|
|
return len(ims)
|
|
}
|
|
showSeen := func() {
|
|
if len(seen) > 0 {
|
|
out = append(out, map[string]any{"role": "user", "content": seen})
|
|
seen = nil
|
|
}
|
|
}
|
|
for _, m := range ms {
|
|
var blocks []any
|
|
var calls []map[string]any
|
|
var text string
|
|
flush := func() {
|
|
if len(blocks) == 0 {
|
|
return
|
|
}
|
|
if m.Role == "user" && len(seen) > 0 {
|
|
blocks, seen = append(seen, blocks...), nil
|
|
}
|
|
out = append(out, map[string]any{"role": m.Role, "content": blocks})
|
|
blocks = nil
|
|
}
|
|
if m.Role != "user" {
|
|
showSeen()
|
|
}
|
|
for _, p := range m.Parts {
|
|
switch p.Kind {
|
|
case Text:
|
|
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
|
|
if text == "" {
|
|
text = p.Text
|
|
}
|
|
case Image:
|
|
if p.Data != "" {
|
|
blocks = append(blocks, map[string]any{"type": "image_url",
|
|
"image_url": map[string]any{"url": "data:" + p.MediaType + ";base64," + p.Data}})
|
|
}
|
|
case ToolCall:
|
|
if m.Role != "assistant" {
|
|
continue
|
|
}
|
|
id := p.ID
|
|
if id == "" {
|
|
id = "call_" + qoder.NewID()
|
|
}
|
|
names[id] = p.Name
|
|
calls = append(calls, map[string]any{"id": id, "type": "function",
|
|
"function": map[string]any{"name": p.Name, "arguments": string(argsOf(p))}})
|
|
case ToolResult:
|
|
flush()
|
|
txt := p.Text
|
|
if p.IsError {
|
|
txt = "Error: " + txt
|
|
}
|
|
if n := seeLater(p); n > 0 {
|
|
note := fmt.Sprintf("[The tool returned %d images; they follow in the next message.]", n)
|
|
if n == 1 {
|
|
note = "[The tool returned an image; it follows in the next message.]"
|
|
}
|
|
if strings.TrimSpace(txt) != "" {
|
|
txt += "\n\n"
|
|
}
|
|
txt += note
|
|
}
|
|
out = append(out, map[string]any{"role": "tool", "tool_call_id": p.CallID, "content": txt})
|
|
}
|
|
}
|
|
if m.Role == "assistant" && len(calls) > 0 {
|
|
out = append(out, map[string]any{"role": "assistant", "content": text, "tool_calls": calls})
|
|
continue
|
|
}
|
|
flush()
|
|
}
|
|
showSeen()
|
|
return out
|
|
}
|
|
|
|
// qoderTools is the caller's tools as Qoder's own native function tools.
|
|
func qoderTools(ts []Tool) []any {
|
|
var out []any
|
|
for _, t := range ts {
|
|
fn := map[string]any{"name": t.Name, "description": t.Description}
|
|
sch := t.Schema
|
|
if strings.TrimSpace(string(sch)) == "" {
|
|
sch = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
fn["parameters"] = json.RawMessage(sch)
|
|
out = append(out, map[string]any{"type": "function", "function": fn})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// qoderEffort is whether to think and at which of the model's own efforts
|
|
// ("" to send none). Asked for none, a model that always thinks thinks at
|
|
// its lowest; asked for nothing, or for a level Qoder doesn't name, it
|
|
// thinks at its own default.
|
|
func qoderEffort(asked string, model qoder.ModelInfo) (bool, string) {
|
|
want := strings.ToLower(asked)
|
|
if !slices.Contains(effortRank, want) {
|
|
want = ""
|
|
}
|
|
if !model.Thinks || want == "none" && !model.AlwaysThinks {
|
|
return false, ""
|
|
}
|
|
if len(model.Efforts) == 0 {
|
|
return true, ""
|
|
}
|
|
switch want {
|
|
case "":
|
|
return true, model.DefaultEffort
|
|
case "none":
|
|
return true, model.Efforts[0]
|
|
}
|
|
return true, fitEffort(want, model.Efforts)
|
|
}
|
|
|
|
// qoderSysText is the system prompt: Qoder's own line plus the caller's.
|
|
// The caller's tools go to Qoder as native function tools.
|
|
func qoderSysText(req *Request) string {
|
|
sys := qoderSys
|
|
if req.System != "" {
|
|
sys = sys + "\n\n" + req.System
|
|
}
|
|
if req.ToolChoice == "required" && len(req.Tools) > 0 {
|
|
sys += "\nYou must call an available function in this response."
|
|
}
|
|
return sys
|
|
}
|
|
|
|
// decodeQoder turns Qoder's SSE reply into events: each "data:" line carries an
|
|
// envelope whose "body" is a standard OpenAI chunk, whose content may hold
|
|
// embedded tool calls to lift out.
|
|
func decodeQoder(ctx context.Context, res *http.Response, out chan<- Event, model string) {
|
|
defer func() { res.Body.Close(); close(out) }()
|
|
send := func(ev Event) bool {
|
|
select {
|
|
case out <- ev:
|
|
return true
|
|
case <-ctx.Done():
|
|
return false
|
|
}
|
|
}
|
|
if !send(Event{Kind: KStart, MsgID: "msg_" + randomToken(), Model: model}) {
|
|
return
|
|
}
|
|
a := qoderText{tool: -1}
|
|
finish := func() {
|
|
for _, frag := range a.flush() {
|
|
if frag.text != "" && !send(Event{Kind: KText, Text: frag.text}) {
|
|
return
|
|
}
|
|
}
|
|
stop := "stop"
|
|
if a.sawTool {
|
|
stop = "tool"
|
|
}
|
|
send(Event{Kind: KStop, Stop: stop})
|
|
}
|
|
sc := bufio.NewScanner(res.Body)
|
|
sc.Buffer(make([]byte, 0, 64<<10), 32<<20)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if payload == "" || !gjson.Valid(payload) {
|
|
continue
|
|
}
|
|
inner := gjson.Get(payload, "body").String()
|
|
if status := gjson.Get(payload, "statusCodeValue"); status.Exists() && status.Int() != 200 {
|
|
data := []byte(inner)
|
|
if len(data) == 0 {
|
|
data = []byte(payload)
|
|
}
|
|
code, msg := qoderFailure(int(status.Int()), data)
|
|
send(Event{Kind: KError, Status: code, Text: msg})
|
|
return
|
|
}
|
|
if inner == "" || !json.Valid([]byte(inner)) {
|
|
continue
|
|
}
|
|
delta := gjson.Get(inner, "choices.0.delta")
|
|
if rc := delta.Get("reasoning_content").String(); rc != "" {
|
|
if !send(Event{Kind: KThink, Text: rc}) {
|
|
return
|
|
}
|
|
}
|
|
if content := delta.Get("content"); content.Exists() {
|
|
for _, frag := range a.feed(content.String()) {
|
|
ev := Event{}
|
|
if frag.call != nil {
|
|
ev = Event{Kind: KToolStart, ID: "call_" + qoder.NewID(), Name: frag.call.name}
|
|
if !send(ev) {
|
|
return
|
|
}
|
|
ev = Event{Kind: KToolArgs, Text: frag.call.args}
|
|
} else {
|
|
ev = Event{Kind: KText, Text: frag.text}
|
|
}
|
|
if !send(ev) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// Qoder also serves tool calls in the OpenAI way: the id it sends
|
|
// is the one a result is answered with, so it is kept.
|
|
for _, tc := range delta.Get("tool_calls").Array() {
|
|
idx := int(tc.Get("index").Int())
|
|
if idx != a.tool {
|
|
a.tool = idx
|
|
for _, frag := range a.flush() {
|
|
if frag.text != "" && !send(Event{Kind: KText, Text: frag.text}) {
|
|
return
|
|
}
|
|
}
|
|
id := tc.Get("id").String()
|
|
if id == "" {
|
|
id = "call_" + qoder.NewID()
|
|
}
|
|
if !send(Event{Kind: KToolStart, ID: id, Name: tc.Get("function.name").String()}) {
|
|
return
|
|
}
|
|
a.sawTool = true
|
|
}
|
|
if args := tc.Get("function.arguments").String(); args != "" {
|
|
if !send(Event{Kind: KToolArgs, Text: args}) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if fr := gjson.Get(inner, "choices.0.finish_reason").String(); fr != "" {
|
|
if u := gjson.Get(inner, "usage"); u.Exists() {
|
|
send(Event{Kind: KUsage, Usage: Usage{
|
|
Input: int(u.Get("prompt_tokens").Int()), Output: int(u.Get("completion_tokens").Int())}})
|
|
}
|
|
finish()
|
|
return
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
send(Event{Kind: KError, Status: 502, Text: "Qoder: reading stream: " + err.Error()})
|
|
return
|
|
}
|
|
finish()
|
|
}
|
|
|
|
// qoderText is the streaming tool-call splitter: it holds text until a full
|
|
// tool-call block arrives, then emits it as a call.
|
|
type qoderText struct {
|
|
textBuf string
|
|
callBuf string
|
|
inCall bool
|
|
sawTool bool
|
|
tool int // index of the native tool call being assembled, -1 for none
|
|
}
|
|
|
|
type qoderFrag struct {
|
|
text string
|
|
call *qoderCall
|
|
}
|
|
type qoderCall struct{ name, args string }
|
|
|
|
func (a *qoderText) feed(input string) []qoderFrag {
|
|
var out []qoderFrag
|
|
for input != "" {
|
|
if !a.inCall {
|
|
comb := a.textBuf + input
|
|
a.textBuf = ""
|
|
i := strings.Index(comb, qoderCallOpen)
|
|
if i < 0 {
|
|
if keep := qoderSuffix(comb); keep < len(comb) {
|
|
out = append(out, qoderFrag{text: comb[:len(comb)-keep]})
|
|
a.textBuf = comb[len(comb)-keep:]
|
|
} else {
|
|
a.textBuf = comb
|
|
}
|
|
break
|
|
}
|
|
if i > 0 {
|
|
out = append(out, qoderFrag{text: comb[:i]})
|
|
}
|
|
input = comb[i+len(qoderCallOpen):]
|
|
a.inCall = true
|
|
continue
|
|
}
|
|
comb := a.callBuf + input
|
|
a.callBuf = ""
|
|
j := strings.Index(comb, qoderCallClose)
|
|
if j < 0 {
|
|
a.callBuf = comb
|
|
break
|
|
}
|
|
if c, ok := qoderParseCall(comb[:j]); ok {
|
|
a.sawTool = true
|
|
out = append(out, qoderFrag{call: &c})
|
|
} else {
|
|
out = append(out, qoderFrag{text: qoderCallOpen + comb[:j] + qoderCallClose})
|
|
}
|
|
input = comb[j+len(qoderCallClose):]
|
|
a.inCall = false
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *qoderText) flush() []qoderFrag {
|
|
if a.inCall {
|
|
t := qoderCallOpen + a.callBuf
|
|
a.inCall, a.callBuf = false, ""
|
|
if t != "" {
|
|
return []qoderFrag{{text: t}}
|
|
}
|
|
}
|
|
if a.textBuf != "" {
|
|
t := a.textBuf
|
|
a.textBuf = ""
|
|
return []qoderFrag{{text: t}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// qoderSuffix is the length of the tail that could still become a call marker.
|
|
func qoderSuffix(v string) int {
|
|
for size := len(qoderCallOpen) - 1; size > 0; size-- {
|
|
if strings.HasSuffix(v, qoderCallOpen[:size]) {
|
|
return size
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// qoderParseCall reads one tool-call block: a JSON {name,arguments}, or the
|
|
// XML function/parameter form.
|
|
func qoderParseCall(v string) (qoderCall, bool) {
|
|
var framed struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if json.Unmarshal([]byte(strings.TrimSpace(v)), &framed) == nil && strings.TrimSpace(framed.Name) != "" {
|
|
var m map[string]json.RawMessage
|
|
if json.Unmarshal(framed.Arguments, &m) == nil && m != nil {
|
|
return qoderCall{name: strings.TrimSpace(framed.Name), args: string(framed.Arguments)}, true
|
|
}
|
|
}
|
|
m := qoderFunc.FindStringSubmatch(v)
|
|
if len(m) != 3 {
|
|
return qoderCall{}, false
|
|
}
|
|
name := strings.TrimSpace(m[1])
|
|
if name == "" {
|
|
return qoderCall{}, false
|
|
}
|
|
args := map[string]any{}
|
|
for _, pm := range qoderArg.FindAllStringSubmatch(m[2], -1) {
|
|
key := strings.TrimSpace(pm[1])
|
|
if key == "" {
|
|
continue
|
|
}
|
|
// A value is taken as it stands: nothing is told to escape them.
|
|
val := strings.TrimSpace(pm[2])
|
|
var dec any
|
|
if json.Unmarshal([]byte(val), &dec) != nil {
|
|
dec = val
|
|
}
|
|
args[key] = dec
|
|
}
|
|
b, err := json.Marshal(args)
|
|
if err != nil {
|
|
return qoderCall{}, false
|
|
}
|
|
return qoderCall{name: name, args: string(b)}, true
|
|
}
|