Files
2026-10-23 18:35:44 +08:00

517 lines
12 KiB
Go

package protocol
import (
"crypto/rand"
"encoding/json"
"fmt"
"strings"
"time"
)
func newID() string { return fmt.Sprintf("resp_%x", randomID()) }
func randomID() []byte {
b := make([]byte, 16)
if _, e := rand.Read(b); e != nil {
panic(e)
}
return b
}
func contentBlock(k Kind, p part, role string) (object, error) {
switch p.typ {
case "text":
typ := "text"
if k == Responses {
typ = "input_text"
if role == "assistant" {
typ = "output_text"
}
}
return object{"type": typ, "text": p.text}, nil
case "image":
if role != "user" {
return nil, fmt.Errorf("images only supported in user messages")
}
switch k {
case Chat:
return object{"type": "image_url", "image_url": object{"url": p.url}}, nil
case Responses:
return object{"type": "input_image", "image_url": p.url}, nil
default:
src := object{"type": "url", "url": p.url}
if strings.HasPrefix(p.url, "data:") {
a, b, ok := strings.Cut(strings.TrimPrefix(p.url, "data:"), ";base64,")
if !ok || a == "" || b == "" {
return nil, fmt.Errorf("unsupported image data URL")
}
src = object{"type": "base64", "media_type": a, "data": b}
}
return object{"type": "image", "source": src}, nil
}
case "call":
if role != "assistant" {
return nil, fmt.Errorf("tool call outside assistant message")
}
switch k {
case Chat:
return object{"id": p.id, "type": "function", "function": object{"name": p.name, "arguments": p.args}}, nil
case Responses:
return object{"type": "function_call", "call_id": p.id, "name": p.name, "arguments": p.args}, nil
default:
args, e := decode([]byte(p.args))
if e != nil {
return nil, e
}
return object{"type": "tool_use", "id": p.id, "name": p.name, "input": args}, nil
}
case "result":
if role != "tool" && role != "user" {
return nil, fmt.Errorf("tool result outside user/tool message")
}
switch k {
case Chat:
return object{"role": "tool", "tool_call_id": p.id, "content": p.text}, nil
case Responses:
return object{"type": "function_call_output", "call_id": p.id, "output": p.text}, nil
default:
return object{"type": "tool_result", "tool_use_id": p.id, "content": p.text}, nil
}
}
return nil, fmt.Errorf("unsupported part")
}
func writeRequest(k Kind, r request) (object, error) {
out := object{}
for key, val := range r.options {
switch key {
case "max":
switch k {
case Chat:
key = "max_completion_tokens"
case Responses:
key = "max_output_tokens"
case Anthropic:
key = "max_tokens"
}
case "stop":
if k == Responses {
return nil, fmt.Errorf("Responses does not support stop sequences")
}
if k == Anthropic {
key = "stop_sequences"
}
case "parallel_tool_calls":
if k == Anthropic {
continue
}
}
out[key] = val
}
if k == Anthropic {
if _, ok := out["max_tokens"]; !ok {
return nil, fmt.Errorf("Anthropic requires explicit max_tokens")
}
}
ms := []any{}
sys := []any{}
seenNonSystem := false
for _, m := range r.messages {
if k == Anthropic && (m.role == "system" || m.role == "developer") {
if m.role == "developer" {
return nil, fmt.Errorf("developer role cannot be represented by Anthropic")
}
if seenNonSystem {
return nil, fmt.Errorf("mid-conversation system message cannot be represented by Anthropic")
}
for _, p := range m.parts {
if p.typ != "text" {
return nil, fmt.Errorf("non-text system content")
}
sys = append(sys, object{"type": "text", "text": p.text})
}
continue
}
seenNonSystem = true
role := m.role
if k == Anthropic && role == "tool" {
role = "user"
}
cs := []any{}
calls := []any{}
flush := func() {
if len(cs) > 0 {
ms = append(ms, object{"role": role, "content": cs})
cs = []any{}
}
}
for _, p := range m.parts {
b, e := contentBlock(k, p, m.role)
if e != nil {
return nil, e
}
switch {
case k == Chat && p.typ == "call":
calls = append(calls, b)
case (k == Chat && p.typ == "result") || (k == Responses && (p.typ == "call" || p.typ == "result")):
flush()
ms = append(ms, b)
default:
cs = append(cs, b)
}
}
if k == Chat && len(calls) > 0 {
x := object{"role": role, "tool_calls": calls, "content": nil}
if len(cs) > 0 {
x["content"] = cs
}
ms = append(ms, x)
} else {
flush()
}
}
if k == Anthropic {
merged := []any{}
for _, v := range ms {
m := obj(v)
if len(merged) > 0 && obj(merged[len(merged)-1])["role"] == m["role"] {
last := obj(merged[len(merged)-1])
last["content"] = append(arr(last["content"]), arr(m["content"])...)
} else {
merged = append(merged, m)
}
}
ms = merged
if len(sys) > 0 {
out["system"] = sys
}
}
if k == Responses {
out["input"] = ms
out["store"] = false
} else {
out["messages"] = ms
}
if len(r.tools) > 0 {
ts := []any{}
for _, f := range r.tools {
switch k {
case Chat:
ts = append(ts, object{"type": "function", "function": f})
case Responses:
t := object{"type": "function"}
for key, v := range f {
t[key] = v
}
ts = append(ts, t)
case Anthropic:
t := object{}
for key, v := range f {
if key == "strict" {
if v != false {
return nil, fmt.Errorf("strict tool schema cannot be enforced by Anthropic")
}
continue
}
if key == "parameters" {
key = "input_schema"
}
t[key] = v
}
ts = append(ts, t)
}
}
out["tools"] = ts
}
if r.choice != nil {
if s, ok := r.choice.(string); ok {
if k == Anthropic {
if s == "required" {
s = "any"
}
out["tool_choice"] = object{"type": s}
} else {
out["tool_choice"] = s
}
} else {
name := obj(r.choice)["name"]
switch k {
case Chat:
out["tool_choice"] = object{"type": "function", "function": object{"name": name}}
case Responses:
out["tool_choice"] = object{"type": "function", "name": name}
case Anthropic:
out["tool_choice"] = object{"type": "tool", "name": name}
}
}
}
if k == Anthropic {
if v, ok := r.options["parallel_tool_calls"]; ok {
b, ok := v.(bool)
if !ok {
return nil, fmt.Errorf("invalid parallel_tool_calls")
}
tc := obj(out["tool_choice"])
if tc == nil {
tc = object{"type": "auto"}
out["tool_choice"] = tc
}
tc["disable_parallel_tool_use"] = !b
}
}
if k == Chat && out["stream"] == true {
out["stream_options"] = object{"include_usage": true}
}
return out, nil
}
type response struct {
id, model, finish string
parts []part
usage Usage
}
func finishReason(k Kind, s string) (string, error) {
switch k {
case Chat:
switch s {
case "stop":
return "stop", nil
case "tool_calls":
return "tool", nil
case "length":
return "length", nil
}
case Anthropic:
switch s {
case "end_turn":
return "stop", nil
case "tool_use":
return "tool", nil
case "max_tokens":
return "length", nil
}
case Responses:
if s == "completed" {
return "stop", nil
}
}
return "", fmt.Errorf("unsupported finish reason %q", s)
}
func wireFinish(k Kind, s string) string {
switch k {
case Chat:
switch s {
case "tool":
return "tool_calls"
case "length":
return "length"
}
return "stop"
case Anthropic:
switch s {
case "tool":
return "tool_use"
case "length":
return "max_tokens"
}
return "end_turn"
default:
if s == "length" {
return "incomplete"
}
return "completed"
}
}
func ConvertResponse(from, to Kind, body []byte) ([]byte, error) {
if !valid(from) || !valid(to) {
return nil, fmt.Errorf("unknown protocol")
}
m, e := decode(body)
if e != nil {
return nil, e
}
if from == to {
return body, nil
}
r, e := readResponse(from, m)
if e != nil {
return nil, e
}
out, e := writeResponse(to, r)
if e != nil {
return nil, e
}
return json.Marshal(out)
}
func readResponse(k Kind, m object) (response, error) {
r := response{id: str(m["id"]), model: str(m["model"]), usage: usage(k, obj(m["usage"]))}
if m["error"] != nil {
return r, fmt.Errorf("upstream error: %v", m["error"])
}
var e error
switch k {
case Chat:
if e = fields(m, "id", "object", "created", "model", "choices", "usage", "system_fingerprint", "service_tier"); e != nil {
return r, e
}
cs := arr(m["choices"])
if len(cs) != 1 {
return r, fmt.Errorf("expected one choice")
}
c := obj(cs[0])
if e = fields(c, "index", "message", "finish_reason", "logprobs"); e != nil {
return r, e
}
if c["logprobs"] != nil {
return r, fmt.Errorf("logprobs unsupported")
}
message, e := chatResponseMessage(obj(c["message"]))
if e != nil {
return r, e
}
mm, e := readMessage(k, message)
if e != nil {
return r, e
}
r.parts = mm.parts
r.finish, e = finishReason(k, str(c["finish_reason"]))
if e != nil {
return r, e
}
case Anthropic:
if e = fields(m, "id", "type", "role", "model", "content", "stop_reason", "stop_sequence", "usage"); e != nil {
return r, e
}
if m["stop_sequence"] != nil {
return r, fmt.Errorf("stop sequence cannot be preserved")
}
content, e := anthropicOutput(m["content"])
if e != nil {
return r, e
}
r.parts, e = readContent(k, content)
if e != nil {
return r, e
}
r.finish, e = finishReason(k, str(m["stop_reason"]))
if e != nil {
return r, e
}
case Responses:
// Responses echoes request configuration; only output items carry generated content.
for _, v := range arr(m["output"]) {
x := obj(v)
switch str(x["type"]) {
case "reasoning":
if e = validateReasoning(x); e != nil {
return r, e
}
case "message":
x = responseMessage(x)
mm, e := readMessage(k, x)
if e != nil {
return r, e
}
r.parts = append(r.parts, mm.parts...)
case "function_call":
if e = fields(x, "type", "id", "call_id", "name", "arguments", "status"); e != nil {
return r, e
}
p, e := callPart(x, "call_id", "arguments")
if e != nil {
return r, e
}
r.parts = append(r.parts, p)
default:
return r, fmt.Errorf("unsupported output item %q", str(x["type"]))
}
}
if str(m["status"]) == "incomplete" {
if str(obj(m["incomplete_details"])["reason"]) != "max_output_tokens" {
return r, fmt.Errorf("unsupported incomplete reason")
}
r.finish = "length"
} else {
r.finish, e = finishReason(k, str(m["status"]))
if e != nil {
return r, e
}
for _, p := range r.parts {
if p.typ == "call" {
r.finish = "tool"
}
}
}
}
return r, nil
}
func writeResponse(k Kind, r response) (object, error) {
if r.id == "" {
r.id = newID()
}
out := object{"id": r.id, "model": r.model}
cs := []any{}
calls := []any{}
text := ""
for i, p := range r.parts {
if p.typ != "text" && p.typ != "call" {
return nil, fmt.Errorf("unsupported response content")
}
b, e := contentBlock(k, p, "assistant")
if e != nil {
return nil, e
}
switch k {
case Chat:
if p.typ == "call" {
calls = append(calls, b)
} else {
text += p.text
}
case Anthropic:
cs = append(cs, b)
case Responses:
if p.typ == "call" {
b["id"] = fmt.Sprintf("fc_%s_%d", r.id, i)
b["status"] = "completed"
cs = append(cs, b)
} else {
b["annotations"] = []any{}
cs = append(cs, object{"type": "message", "id": fmt.Sprintf("msg_%s_%d", r.id, i), "role": "assistant", "status": "completed", "content": []any{b}})
}
}
}
switch k {
case Chat:
m := object{"role": "assistant", "content": text}
if len(calls) > 0 {
m["tool_calls"] = calls
if text == "" {
m["content"] = nil
}
}
out["object"] = "chat.completion"
out["created"] = 0
out["choices"] = []any{object{"index": 0, "message": m, "finish_reason": wireFinish(k, r.finish)}}
case Anthropic:
out["type"] = "message"
out["role"] = "assistant"
out["content"] = cs
out["stop_reason"] = wireFinish(k, r.finish)
out["stop_sequence"] = nil
case Responses:
out["object"] = "response"
out["created_at"] = time.Now().Unix()
out["status"] = wireFinish(k, r.finish)
out["output"] = cs
out["error"] = nil
out["incomplete_details"] = nil
if r.finish == "length" {
out["incomplete_details"] = object{"reason": "max_output_tokens"}
}
}
if r.usage.Known || k == Anthropic {
out["usage"] = usageJSON(k, r.usage)
}
return out, nil
}