517 lines
12 KiB
Go
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
|
|
}
|