chore: initial commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Protocol conversion
|
||||
|
||||
Public entry points are `ConvertRequest`, `ConvertResponse`, `Stream`, and
|
||||
`ExtractUsage`. No external dependencies are needed. The service should bypass
|
||||
conversion for same-protocol non-streaming traffic. `Stream` preserves original
|
||||
SSE bytes for same-protocol traffic, including comments and CRLF, and returns
|
||||
upstream errors instead of silently swallowing them.
|
||||
|
||||
Cross-protocol support includes text, user URL/base64 images, system messages,
|
||||
function schemas and selection, multiple function calls, tool results, multi-turn
|
||||
history, and incremental tool argument JSON. Responses output item/content part/
|
||||
argument start, delta, done and terminal events are emitted. Anthropic content
|
||||
block and message lifecycles are emitted. Each write flushes when supported.
|
||||
|
||||
## Deliberate limits
|
||||
|
||||
- Anthropic output thinking/redacted_thinking blocks and their thinking/signature
|
||||
stream deltas are omitted when converting to Chat/Responses, never appended to
|
||||
visible text. Block indices and lifecycle are checked. Native Anthropic output
|
||||
bytes remain unchanged; request-side thinking is not translated.
|
||||
|
||||
- Chat output message/delta reasoning_content (string or null) is omitted only
|
||||
during cross-protocol conversion; it is never appended to visible text or
|
||||
fabricated as Anthropic thinking. Native Chat responses/SSE remain unchanged.
|
||||
Optional indices on complete Chat tool calls must match their array positions.
|
||||
|
||||
- Responses output message `phase` is accepted as metadata. Cross-protocol
|
||||
conversion deliberately omits Responses output reasoning, summaries and
|
||||
encrypted content, including their recognized stream events. They are never
|
||||
appended to visible text or converted to Anthropic thinking/signatures.
|
||||
Same-protocol responses and SSE retain these bytes unchanged. Reasoning
|
||||
item/part indices, identities and lifecycles are checked; unrelated unknown
|
||||
content and event types remain errors. Empty output-text logprobs arrays
|
||||
are accepted, but actual logprobs remain unsupported.
|
||||
- Only one choice and function tools are supported. Other reasoning/thinking blocks,
|
||||
audio/video/documents, hosted tools, citations, refusal content, logprobs,
|
||||
structured-output modes, prompt-cache controls and Responses server-side
|
||||
conversation state are rejected rather than approximated. Unknown request
|
||||
fields and unknown stream event types are rejected.
|
||||
- Anthropic targets require an explicit token limit. Developer roles,
|
||||
mid-conversation system messages, strict tool schemas and explicit image
|
||||
detail levels cannot be translated to Anthropic. The implementation currently
|
||||
conservatively rejects explicit image detail for all cross-protocol pairs.
|
||||
- Tool results must be text and cannot carry Anthropic's `is_error=true`.
|
||||
Stop sequences cannot target Responses; stop-sequence termination cannot
|
||||
be represented losslessly across protocols.
|
||||
- Responses streaming supports one output-text content part per message.
|
||||
Final content must agree with deltas; nonincremental/final-only variants
|
||||
are rejected. Tool identity must be complete before argument streaming;
|
||||
later ID/name fragments are rejected. Parallel tool argument fragments are
|
||||
tracked independently.
|
||||
- `Usage.Input` includes cache-read and cache-write tokens. Anthropic input
|
||||
counters exclude these, so they are added on extraction and subtracted on
|
||||
encoding. `Usage.Raw` holds upstream usage JSON; Anthropic stream snapshots
|
||||
are merged so final output-only usage does not erase initial input counts.
|
||||
Unknown usage remains `Known=false`; Anthropic wire messages still carry
|
||||
required zero counters until actual usage is available. OpenAI wire schemas
|
||||
have no separate Anthropic cache-write counter; it remains in returned Usage.
|
||||
- SSE events are limited to 16 MiB; generated content is retained for final
|
||||
Responses snapshots and argument validation. LF and CRLF framing is supported
|
||||
(not standalone CR). Same-protocol streams allow EOF without a terminal marker;
|
||||
cross-protocol streams report truncation. Terminal markers finish immediately.
|
||||
- Context is checked between reads and writes. A generic `io.Reader` cannot be
|
||||
interrupted safely: the service must bind upstream HTTP requests to the same
|
||||
context or close their body on cancellation. Writers/readers remain caller-owned.
|
||||
- Response transport metadata and echoed Responses request settings are not
|
||||
round-tripped. Generated IDs use cryptographic randomness if upstream omits
|
||||
an ID. No external provider or SDK integration tests have been performed.
|
||||
|
||||
Run `go test ./internal/protocol`, `go test -race -cover ./internal/protocol`,
|
||||
and `go vet ./internal/protocol`.
|
||||
@@ -0,0 +1,101 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnthropicThinkingOutput(t *testing.T) {
|
||||
for _, tool := range []bool{false, true} {
|
||||
visible := `{"type":"text","text":"OK"}`
|
||||
initial := `{"type":"text","text":""}`
|
||||
delta := `{"type":"text_delta","text":"OK"}`
|
||||
finish := "end_turn"
|
||||
if tool {
|
||||
visible = `{"type":"tool_use","id":"call_1","name":"weather","input":{}}`
|
||||
initial = visible
|
||||
delta = `{"type":"input_json_delta","partial_json":"{}"}`
|
||||
finish = "tool_use"
|
||||
}
|
||||
hidden := `{"type":"thinking","thinking":"PRIVATE","signature":"SIGNATURE"}`
|
||||
redacted := `{"type":"redacted_thinking","data":"CIPHER"}`
|
||||
body := `{"id":"m","type":"message","role":"assistant","model":"m","content":[` + hidden + "," + redacted + "," + visible + `],"stop_reason":"` + finish + `","usage":{"input_tokens":9,"output_tokens":7}}`
|
||||
events := []string{
|
||||
`{"type":"message_start","message":{"id":"m","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}`,
|
||||
`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}`,
|
||||
`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"PRIVATE"}}`,
|
||||
`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIGNATURE"}}`,
|
||||
`{"type":"content_block_stop","index":0}`,
|
||||
`{"type":"content_block_start","index":1,"content_block":` + redacted + "}",
|
||||
`{"type":"content_block_stop","index":1}`,
|
||||
`{"type":"content_block_start","index":2,"content_block":` + initial + "}",
|
||||
`{"type":"content_block_delta","index":2,"delta":` + delta + "}",
|
||||
`{"type":"content_block_stop","index":2}`,
|
||||
`{"type":"message_delta","delta":{"stop_reason":"` + finish + `"},"usage":{"output_tokens":7}}`,
|
||||
`{"type":"message_stop"}`,
|
||||
}
|
||||
src := reasoningSSE(events...)
|
||||
for _, target := range []Kind{Chat, Responses, Anthropic} {
|
||||
out, err := ConvertResponse(Anthropic, target, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
u, err := Stream(context.Background(), &dst, strings.NewReader(src), Anthropic, target, "m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.Known || u.Input != 9 || u.Output != 7 {
|
||||
t.Fatalf("usage %+v", u)
|
||||
}
|
||||
if target == Anthropic {
|
||||
if string(out) != body || dst.String() != src {
|
||||
t.Fatal("native bytes changed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, result := range []string{string(out), dst.String()} {
|
||||
for _, secret := range []string{"PRIVATE", "SIGNATURE", "CIPHER", "thinking"} {
|
||||
if strings.Contains(result, secret) {
|
||||
t.Fatal("thinking leaked")
|
||||
}
|
||||
}
|
||||
want := "OK"
|
||||
if tool {
|
||||
want = "weather"
|
||||
}
|
||||
if !strings.Contains(result, want) {
|
||||
t.Fatal("lost visible output")
|
||||
}
|
||||
}
|
||||
terminal := "[DONE]"
|
||||
if target == Responses {
|
||||
terminal = "response.completed"
|
||||
}
|
||||
if !strings.Contains(dst.String(), terminal) {
|
||||
t.Fatal("no terminal")
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{
|
||||
strings.Replace(src, `{"type":"content_block_stop","index":0}`, `{"type":"ping"}`, 1),
|
||||
strings.Replace(src, `"type":"thinking_delta"`, `"type":"text_delta"`, 1),
|
||||
strings.Replace(src, `"index":2,"content_block"`, `"index":0,"content_block"`, 1),
|
||||
} {
|
||||
var dst bytes.Buffer
|
||||
if _, err := Stream(context.Background(), &dst, strings.NewReader(bad), Anthropic, Chat, "m"); err == nil {
|
||||
t.Fatal("accepted invalid thinking lifecycle")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, block := range []object{
|
||||
{"type": "thinking", "thinking": true, "signature": ""},
|
||||
{"type": "thinking", "thinking": "", "signature": "", "unknown": true},
|
||||
{"type": "redacted_thinking", "data": nil},
|
||||
} {
|
||||
if _, err := anthropicThinking(block); err == nil {
|
||||
t.Fatal("accepted malformed thinking")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatOutputReasoning(t *testing.T) {
|
||||
for _, tool := range []bool{false, true} {
|
||||
message := object{"role": "assistant", "content": "OK", "reasoning_content": "PRIVATE"}
|
||||
delta := object{"content": "OK", "reasoning_content": "PRIVATE"}
|
||||
finish := "stop"
|
||||
if tool {
|
||||
call := object{"index": 0, "id": "call_1", "type": "function", "function": object{"name": "weather", "arguments": "{}"}}
|
||||
message["content"] = nil
|
||||
message["tool_calls"] = []any{call}
|
||||
delta = object{"tool_calls": []any{call}, "reasoning_content": nil}
|
||||
finish = "tool_calls"
|
||||
}
|
||||
usage := object{"prompt_tokens": 9, "completion_tokens": 7}
|
||||
response := object{"id": "c", "object": "chat.completion", "model": "m", "choices": []any{object{"index": 0, "message": message, "finish_reason": finish}}, "usage": usage}
|
||||
body, _ := json.Marshal(response)
|
||||
chunk, _ := json.Marshal(object{"id": "c", "choices": []any{object{"index": 0, "delta": delta, "finish_reason": finish}}, "usage": usage})
|
||||
reasoning, _ := json.Marshal(object{"id": "c", "choices": []any{object{"index": 0, "delta": object{"role": "assistant", "reasoning_content": "PRIVATE"}, "finish_reason": nil}}})
|
||||
sep := string([]byte{10, 10})
|
||||
src := "data: " + string(reasoning) + sep + "data: " + string(chunk) + sep + "data: [DONE]" + sep
|
||||
for _, target := range []Kind{Chat, Responses, Anthropic} {
|
||||
out, err := ConvertResponse(Chat, target, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dst bytes.Buffer
|
||||
u, err := Stream(context.Background(), &dst, strings.NewReader(src), Chat, target, "m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.Known || u.Input != 9 || u.Output != 7 {
|
||||
t.Fatalf("usage: %+v", u)
|
||||
}
|
||||
if target == Chat {
|
||||
if !bytes.Equal(out, body) || dst.String() != src {
|
||||
t.Fatal("native bytes changed")
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, text := range []string{string(out), dst.String()} {
|
||||
if strings.Contains(text, "PRIVATE") || strings.Contains(text, "reasoning_content") || strings.Contains(text, "thinking") {
|
||||
t.Fatal("reasoning leaked")
|
||||
}
|
||||
want := "OK"
|
||||
if tool {
|
||||
want = "weather"
|
||||
}
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatal("lost output")
|
||||
}
|
||||
}
|
||||
terminal := "response.completed"
|
||||
if target == Anthropic {
|
||||
terminal = "message_stop"
|
||||
}
|
||||
if !strings.Contains(dst.String(), terminal) {
|
||||
t.Fatal("no terminal")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatOutputMetadataValidation(t *testing.T) {
|
||||
for _, val := range []any{nil, "", "private"} {
|
||||
if _, err := chatOutput(object{"reasoning_content": val}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, val := range []any{true, object{}, []any{}} {
|
||||
if _, err := chatOutput(object{"reasoning_content": val}); err == nil {
|
||||
t.Fatal("accepted malformed reasoning")
|
||||
}
|
||||
}
|
||||
for _, index := range []any{json.Number("1"), json.Number("-1"), json.Number("0.5"), "0", nil} {
|
||||
if _, err := chatResponseMessage(object{"tool_calls": []any{object{"index": index}}}); err == nil {
|
||||
t.Fatal("accepted malformed index")
|
||||
}
|
||||
}
|
||||
body := []byte(`{"id":"c","choices":[{"message":{"role":"assistant","content":"OK","reasoning_content":"hidden","unknown_field":true},"finish_reason":"stop"}]}`)
|
||||
if _, err := ConvertResponse(Chat, Responses, body); err == nil {
|
||||
t.Fatal("accepted unknown field")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
Chat Kind = "openai-chat"
|
||||
Responses Kind = "openai-responses"
|
||||
Anthropic Kind = "anthropic"
|
||||
)
|
||||
|
||||
type Usage struct {
|
||||
Input int64 `json:"input_tokens"`
|
||||
Output int64 `json:"output_tokens"`
|
||||
CacheRead int64 `json:"cache_read_tokens"`
|
||||
CacheWrite int64 `json:"cache_write_tokens"`
|
||||
Known bool `json:"known"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
type object = map[string]any
|
||||
|
||||
// maxJSONNodes bounds how many JSON tokens one body may contain before it is
|
||||
// materialized into maps. Without it a 16 MiB body of micro-values expands
|
||||
// into hundreds of MB of Go objects per request.
|
||||
const maxJSONNodes = 1 << 18
|
||||
|
||||
func valid(k Kind) bool { return k == Chat || k == Responses || k == Anthropic }
|
||||
|
||||
// scanNodeCount walks b with the streaming decoder and fails once more than
|
||||
// limit tokens have been seen. Malformed JSON is not an error here; the
|
||||
// materializing pass reports its usual message.
|
||||
func scanNodeCount(b []byte, limit int) error {
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
n := 0
|
||||
for {
|
||||
_, err := d.Token()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if n++; n > limit {
|
||||
return fmt.Errorf("JSON exceeds %d nodes", limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
func decode(b []byte) (object, error) {
|
||||
if err := scanNodeCount(b, maxJSONNodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.UseNumber()
|
||||
var v object
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, fmt.Errorf("expected JSON object")
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return nil, fmt.Errorf("trailing JSON data")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func obj(v any) object { m, _ := v.(map[string]any); return m }
|
||||
func str(v any) string { s, _ := v.(string); return s }
|
||||
func arr(v any) []any { a, _ := v.([]any); return a }
|
||||
func num(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func fields(m object, names ...string) error {
|
||||
allowed := map[string]bool{}
|
||||
for _, n := range names {
|
||||
allowed[n] = true
|
||||
}
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
return fmt.Errorf("unsupported field %q", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func required(m object, k string) (string, error) {
|
||||
s, ok := m[k].(string)
|
||||
if !ok || s == "" {
|
||||
return "", fmt.Errorf("missing or invalid %s", k)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func ExtractUsage(kind Kind, body []byte) Usage {
|
||||
m, e := decode(body)
|
||||
if e != nil {
|
||||
return Usage{}
|
||||
}
|
||||
if r := obj(m["response"]); r != nil {
|
||||
m = r
|
||||
}
|
||||
if r := obj(m["message"]); r != nil {
|
||||
m = r
|
||||
}
|
||||
return usage(kind, obj(m["usage"]))
|
||||
}
|
||||
func usage(k Kind, m object) Usage {
|
||||
if m == nil {
|
||||
return Usage{}
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
u := Usage{Raw: raw}
|
||||
switch k {
|
||||
case Chat:
|
||||
_, a := m["prompt_tokens"]
|
||||
_, b := m["completion_tokens"]
|
||||
u.Known = a || b
|
||||
u.Input = num(m["prompt_tokens"])
|
||||
u.Output = num(m["completion_tokens"])
|
||||
u.CacheRead = num(obj(m["prompt_tokens_details"])["cached_tokens"])
|
||||
case Responses:
|
||||
_, a := m["input_tokens"]
|
||||
_, b := m["output_tokens"]
|
||||
u.Known = a || b
|
||||
u.Input = num(m["input_tokens"])
|
||||
u.Output = num(m["output_tokens"])
|
||||
u.CacheRead = num(obj(m["input_tokens_details"])["cached_tokens"])
|
||||
case Anthropic:
|
||||
_, a := m["input_tokens"]
|
||||
_, b := m["output_tokens"]
|
||||
u.Known = a || b
|
||||
u.CacheRead = num(m["cache_read_input_tokens"])
|
||||
u.CacheWrite = num(m["cache_creation_input_tokens"])
|
||||
u.Input = num(m["input_tokens"]) + u.CacheRead + u.CacheWrite
|
||||
u.Output = num(m["output_tokens"])
|
||||
}
|
||||
return u
|
||||
}
|
||||
func usageJSON(k Kind, u Usage) object {
|
||||
switch k {
|
||||
case Chat:
|
||||
return object{"prompt_tokens": u.Input, "completion_tokens": u.Output, "total_tokens": u.Input + u.Output, "prompt_tokens_details": object{"cached_tokens": u.CacheRead}}
|
||||
case Responses:
|
||||
return object{"input_tokens": u.Input, "output_tokens": u.Output, "total_tokens": u.Input + u.Output, "input_tokens_details": object{"cached_tokens": u.CacheRead}}
|
||||
default:
|
||||
return object{"input_tokens": u.Input - u.CacheRead - u.CacheWrite, "output_tokens": u.Output, "cache_read_input_tokens": u.CacheRead, "cache_creation_input_tokens": u.CacheWrite}
|
||||
}
|
||||
}
|
||||
|
||||
type part struct{ typ, text, id, name, args, url string }
|
||||
type message struct {
|
||||
role string
|
||||
parts []part
|
||||
}
|
||||
type request struct {
|
||||
messages []message
|
||||
tools []object
|
||||
options object
|
||||
choice any
|
||||
}
|
||||
|
||||
func textPart(s string) part { return part{typ: "text", text: s} }
|
||||
|
||||
func ConvertRequest(from, to Kind, body []byte, model string) ([]byte, error) {
|
||||
if !valid(from) || !valid(to) {
|
||||
return nil, fmt.Errorf("unknown protocol")
|
||||
}
|
||||
m, err := decode(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from == to {
|
||||
if model != "" {
|
||||
m["model"] = model
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
r, err := readRequest(from, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := writeRequest(to, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if model == "" {
|
||||
model = str(m["model"])
|
||||
}
|
||||
out["model"] = model
|
||||
return json.Marshal(out)
|
||||
}
|
||||
func readRequest(k Kind, m object) (request, error) {
|
||||
r := request{options: object{}}
|
||||
var err error
|
||||
common := []string{"model", "stream", "temperature", "top_p", "tools", "tool_choice"}
|
||||
switch k {
|
||||
case Chat:
|
||||
err = fields(m, append(common, "messages", "max_tokens", "max_completion_tokens", "stop", "stream_options", "parallel_tool_calls", "n")...)
|
||||
case Responses:
|
||||
err = fields(m, append(common, "input", "instructions", "max_output_tokens", "parallel_tool_calls", "store")...)
|
||||
case Anthropic:
|
||||
err = fields(m, append(common, "messages", "system", "max_tokens", "stop_sequences")...)
|
||||
}
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if n, ok := m["n"]; ok && num(n) != 1 {
|
||||
return r, fmt.Errorf("multiple choices unsupported")
|
||||
}
|
||||
if v, ok := m["store"]; ok && v != false {
|
||||
return r, fmt.Errorf("stored Responses unsupported")
|
||||
}
|
||||
if v, ok := m["stream_options"]; ok {
|
||||
o := obj(v)
|
||||
if o == nil {
|
||||
return r, fmt.Errorf("invalid stream_options")
|
||||
}
|
||||
if e := fields(o, "include_usage"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"stream", "temperature", "top_p", "parallel_tool_calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
r.options[key] = v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"max_tokens", "max_completion_tokens", "max_output_tokens"} {
|
||||
if v, ok := m[key]; ok {
|
||||
if _, exists := r.options["max"]; exists {
|
||||
return r, fmt.Errorf("conflicting token limits")
|
||||
}
|
||||
r.options["max"] = v
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"stop", "stop_sequences"} {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
v = []any{s}
|
||||
}
|
||||
if _, ok := v.([]any); !ok {
|
||||
return r, fmt.Errorf("invalid stop sequences")
|
||||
}
|
||||
r.options["stop"] = v
|
||||
}
|
||||
}
|
||||
if k == Responses {
|
||||
if s, ok := m["instructions"]; ok {
|
||||
v, ok := s.(string)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("invalid instructions")
|
||||
}
|
||||
r.messages = append(r.messages, message{"system", []part{textPart(v)}})
|
||||
}
|
||||
if s, ok := m["input"].(string); ok {
|
||||
r.messages = append(r.messages, message{"user", []part{textPart(s)}})
|
||||
} else {
|
||||
items, ok := m["input"].([]any)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("invalid input")
|
||||
}
|
||||
for _, v := range items {
|
||||
x := obj(v)
|
||||
typ := str(x["type"])
|
||||
switch typ {
|
||||
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.messages = append(r.messages, message{"assistant", []part{p}})
|
||||
case "function_call_output":
|
||||
if e := fields(x, "type", "id", "call_id", "output", "status"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
id, e := required(x, "call_id")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
s, ok := x["output"].(string)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("non-text function output unsupported")
|
||||
}
|
||||
r.messages = append(r.messages, message{"tool", []part{{typ: "result", id: id, text: s}}})
|
||||
case "", "message":
|
||||
mm, e := readMessage(k, x)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.messages = append(r.messages, mm)
|
||||
default:
|
||||
return r, fmt.Errorf("unsupported input item %q", typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if k == Anthropic {
|
||||
if s, ok := m["system"]; ok {
|
||||
ps, e := readContent(k, s)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
for _, p := range ps {
|
||||
if p.typ != "text" {
|
||||
return r, fmt.Errorf("non-text system")
|
||||
}
|
||||
}
|
||||
r.messages = append(r.messages, message{"system", ps})
|
||||
}
|
||||
}
|
||||
ms, ok := m["messages"].([]any)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("invalid messages")
|
||||
}
|
||||
for _, v := range ms {
|
||||
mm, e := readMessage(k, obj(v))
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.messages = append(r.messages, mm)
|
||||
}
|
||||
}
|
||||
if v, ok := m["tools"]; ok {
|
||||
ts, ok := v.([]any)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("invalid tools")
|
||||
}
|
||||
for _, v := range ts {
|
||||
t := obj(v)
|
||||
var f object
|
||||
switch k {
|
||||
case Chat:
|
||||
if e := fields(t, "type", "function"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
if str(t["type"]) != "function" {
|
||||
return r, fmt.Errorf("unsupported tool")
|
||||
}
|
||||
f = obj(t["function"])
|
||||
case Responses:
|
||||
if str(t["type"]) != "function" {
|
||||
return r, fmt.Errorf("unsupported tool")
|
||||
}
|
||||
f = object{}
|
||||
for key, val := range t {
|
||||
if key != "type" {
|
||||
f[key] = val
|
||||
}
|
||||
}
|
||||
case Anthropic:
|
||||
f = object{}
|
||||
for key, val := range t {
|
||||
if key == "input_schema" {
|
||||
key = "parameters"
|
||||
}
|
||||
f[key] = val
|
||||
}
|
||||
}
|
||||
if e := fields(f, "name", "description", "parameters", "strict"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
if _, e := required(f, "name"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
if obj(f["parameters"]) == nil {
|
||||
return r, fmt.Errorf("missing tool schema")
|
||||
}
|
||||
r.tools = append(r.tools, f)
|
||||
}
|
||||
}
|
||||
if v, ok := m["tool_choice"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
if s != "auto" && s != "none" && s != "required" {
|
||||
return r, fmt.Errorf("unsupported tool_choice")
|
||||
}
|
||||
r.choice = s
|
||||
} else {
|
||||
x := obj(v)
|
||||
if k == Chat {
|
||||
if e := fields(x, "type", "function"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
if str(x["type"]) != "function" {
|
||||
return r, fmt.Errorf("unsupported tool_choice")
|
||||
}
|
||||
f := obj(x["function"])
|
||||
if e := fields(f, "name"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
s, e := required(f, "name")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.choice = object{"name": s}
|
||||
} else if k == Responses {
|
||||
if e := fields(x, "type", "name"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
if str(x["type"]) != "function" {
|
||||
return r, fmt.Errorf("unsupported tool_choice")
|
||||
}
|
||||
s, e := required(x, "name")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.choice = object{"name": s}
|
||||
} else {
|
||||
if e := fields(x, "type", "name", "disable_parallel_tool_use"); e != nil {
|
||||
return r, e
|
||||
}
|
||||
switch str(x["type"]) {
|
||||
case "auto", "none":
|
||||
r.choice = x["type"]
|
||||
case "any":
|
||||
r.choice = "required"
|
||||
case "tool":
|
||||
s, e := required(x, "name")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.choice = object{"name": s}
|
||||
default:
|
||||
return r, fmt.Errorf("unsupported tool_choice")
|
||||
}
|
||||
if b, ok := x["disable_parallel_tool_use"].(bool); ok {
|
||||
r.options["parallel_tool_calls"] = !b
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
func callPart(x object, idKey, argsKey string) (part, error) {
|
||||
id, e := required(x, idKey)
|
||||
if e != nil {
|
||||
return part{}, e
|
||||
}
|
||||
name, e := required(x, "name")
|
||||
if e != nil {
|
||||
return part{}, e
|
||||
}
|
||||
s, ok := x[argsKey].(string)
|
||||
if !ok {
|
||||
return part{}, fmt.Errorf("invalid tool arguments")
|
||||
}
|
||||
if _, e := decode([]byte(s)); e != nil {
|
||||
return part{}, fmt.Errorf("tool arguments must be JSON object: %w", e)
|
||||
}
|
||||
return part{typ: "call", id: id, name: name, args: s}, nil
|
||||
}
|
||||
func readMessage(k Kind, x object) (message, error) {
|
||||
m := message{role: str(x["role"])}
|
||||
if x == nil {
|
||||
return m, fmt.Errorf("invalid message")
|
||||
}
|
||||
if e := fields(x, "role", "content", "type", "id", "status", "tool_calls", "tool_call_id", "refusal"); e != nil {
|
||||
return m, e
|
||||
}
|
||||
if x["refusal"] != nil {
|
||||
return m, fmt.Errorf("refusal content cannot be preserved")
|
||||
}
|
||||
switch m.role {
|
||||
case "user", "assistant", "system", "developer", "tool":
|
||||
default:
|
||||
return m, fmt.Errorf("unsupported role %q", m.role)
|
||||
}
|
||||
if m.role == "tool" {
|
||||
if k != Chat {
|
||||
return m, fmt.Errorf("invalid tool role")
|
||||
}
|
||||
id, e := required(x, "tool_call_id")
|
||||
if e != nil {
|
||||
return m, e
|
||||
}
|
||||
s, ok := x["content"].(string)
|
||||
if !ok {
|
||||
return m, fmt.Errorf("non-text tool result unsupported")
|
||||
}
|
||||
m.parts = []part{{typ: "result", id: id, text: s}}
|
||||
return m, nil
|
||||
}
|
||||
if c := x["content"]; c != nil {
|
||||
ps, e := readContent(k, c)
|
||||
if e != nil {
|
||||
return m, e
|
||||
}
|
||||
m.parts = ps
|
||||
}
|
||||
if tc, ok := x["tool_calls"]; ok {
|
||||
if k != Chat || m.role != "assistant" {
|
||||
return m, fmt.Errorf("invalid tool_calls")
|
||||
}
|
||||
ts, ok := tc.([]any)
|
||||
if !ok {
|
||||
return m, fmt.Errorf("invalid tool_calls")
|
||||
}
|
||||
for _, v := range ts {
|
||||
t := obj(v)
|
||||
if e := fields(t, "id", "type", "function"); e != nil {
|
||||
return m, e
|
||||
}
|
||||
if str(t["type"]) != "function" {
|
||||
return m, fmt.Errorf("unsupported tool call")
|
||||
}
|
||||
f := obj(t["function"])
|
||||
if e := fields(f, "name", "arguments"); e != nil {
|
||||
return m, e
|
||||
}
|
||||
p, e := callPart(object{"id": t["id"], "name": f["name"], "arguments": f["arguments"]}, "id", "arguments")
|
||||
if e != nil {
|
||||
return m, e
|
||||
}
|
||||
m.parts = append(m.parts, p)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
func readContent(k Kind, v any) ([]part, error) {
|
||||
if s, ok := v.(string); ok {
|
||||
return []part{textPart(s)}, nil
|
||||
}
|
||||
a, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid content")
|
||||
}
|
||||
var ps []part
|
||||
for _, v := range a {
|
||||
x := obj(v)
|
||||
switch str(x["type"]) {
|
||||
case "text", "input_text", "output_text":
|
||||
if e := fields(x, "type", "text", "annotations"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if an, ok := x["annotations"]; ok && len(arr(an)) != 0 {
|
||||
return nil, fmt.Errorf("annotations unsupported")
|
||||
}
|
||||
s, ok := x["text"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid text")
|
||||
}
|
||||
ps = append(ps, textPart(s))
|
||||
case "image_url":
|
||||
if k != Chat {
|
||||
return nil, fmt.Errorf("invalid image block")
|
||||
}
|
||||
if e := fields(x, "type", "image_url"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
im := obj(x["image_url"])
|
||||
if e := fields(im, "url", "detail"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if d := str(im["detail"]); d != "" && d != "auto" {
|
||||
return nil, fmt.Errorf("image detail cannot be preserved")
|
||||
}
|
||||
s, e := required(im, "url")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ps = append(ps, part{typ: "image", url: s})
|
||||
case "input_image":
|
||||
if k != Responses {
|
||||
return nil, fmt.Errorf("invalid image block")
|
||||
}
|
||||
if e := fields(x, "type", "image_url", "detail"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if d := str(x["detail"]); d != "" && d != "auto" {
|
||||
return nil, fmt.Errorf("image detail cannot be preserved")
|
||||
}
|
||||
s, e := required(x, "image_url")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ps = append(ps, part{typ: "image", url: s})
|
||||
case "image":
|
||||
if k != Anthropic {
|
||||
return nil, fmt.Errorf("invalid image block")
|
||||
}
|
||||
if e := fields(x, "type", "source"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
src := obj(x["source"])
|
||||
var url string
|
||||
switch str(src["type"]) {
|
||||
case "url":
|
||||
if e := fields(src, "type", "url"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
url = str(src["url"])
|
||||
case "base64":
|
||||
if e := fields(src, "type", "media_type", "data"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if str(src["media_type"]) == "" || str(src["data"]) == "" {
|
||||
return nil, fmt.Errorf("invalid image source")
|
||||
}
|
||||
url = "data:" + str(src["media_type"]) + ";base64," + str(src["data"])
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported image source")
|
||||
}
|
||||
if url == "" {
|
||||
return nil, fmt.Errorf("empty image URL")
|
||||
}
|
||||
ps = append(ps, part{typ: "image", url: url})
|
||||
case "tool_use":
|
||||
if k != Anthropic {
|
||||
return nil, fmt.Errorf("invalid tool block")
|
||||
}
|
||||
if e := fields(x, "type", "id", "name", "input"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
b, e := json.Marshal(x["input"])
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p, e := callPart(object{"id": x["id"], "name": x["name"], "arguments": string(b)}, "id", "arguments")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ps = append(ps, p)
|
||||
case "tool_result":
|
||||
if k != Anthropic {
|
||||
return nil, fmt.Errorf("invalid result block")
|
||||
}
|
||||
if e := fields(x, "type", "tool_use_id", "content", "is_error"); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if x["is_error"] == true {
|
||||
return nil, fmt.Errorf("tool error flag cannot be preserved")
|
||||
}
|
||||
id, e := required(x, "tool_use_id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s, ok := x["content"].(string)
|
||||
if !ok {
|
||||
pp, e := readContent(k, x["content"])
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, p := range pp {
|
||||
if p.typ != "text" {
|
||||
return nil, fmt.Errorf("non-text tool result unsupported")
|
||||
}
|
||||
s += p.text
|
||||
}
|
||||
}
|
||||
ps = append(ps, part{typ: "result", id: id, text: s})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported content type %q", str(x["type"]))
|
||||
}
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestMatrix(t *testing.T) {
|
||||
chat := []byte(`{"model":"old","max_tokens":100,"stream":true,"messages":[{"role":"system","content":"help"},{"role":"user","content":[{"type":"text","text":"look"},{"type":"image_url","image_url":{"url":"data:image/png;base64,YQ=="}}]},{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{\"path\":\"a\"}"}}]},{"role":"tool","tool_call_id":"c1","content":"ok"},{"role":"user","content":"continue"}],"tools":[{"type":"function","function":{"name":"read","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}],"tool_choice":"auto"}`)
|
||||
for _, from := range []Kind{Chat, Responses, Anthropic} {
|
||||
src := chat
|
||||
var e error
|
||||
if from != Chat {
|
||||
src, e = ConvertRequest(Chat, from, chat, "new")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
for _, to := range []Kind{Chat, Responses, Anthropic} {
|
||||
t.Run(string(from)+"/"+string(to), func(t *testing.T) {
|
||||
b, e := ConvertRequest(from, to, src, "target")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
m, e := decode(b)
|
||||
if e != nil || m["model"] != "target" {
|
||||
t.Fatalf("%s %v", b, e)
|
||||
}
|
||||
r, e := readRequest(to, m)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var calls, results, images int
|
||||
for _, m := range r.messages {
|
||||
for _, p := range m.parts {
|
||||
switch p.typ {
|
||||
case "call":
|
||||
calls++
|
||||
if p.id != "c1" {
|
||||
t.Fatal(p)
|
||||
}
|
||||
case "result":
|
||||
results++
|
||||
case "image":
|
||||
images++
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls != 1 || results != 1 || images != 1 {
|
||||
t.Fatalf("lost content: %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestStrictRejection(t *testing.T) {
|
||||
cases := []struct {
|
||||
k Kind
|
||||
b string
|
||||
to Kind
|
||||
}{
|
||||
{Chat, `{"messages":[],"response_format":{"type":"json_object"}}`, Responses},
|
||||
{Chat, `{"messages":[]}`, Anthropic},
|
||||
{Responses, `{"input":[],"previous_response_id":"r"}`, Chat},
|
||||
{Anthropic, `{"max_tokens":10,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"x"}]}]}`, Chat},
|
||||
{Chat, `{"messages":[],"n":2}`, Responses},
|
||||
{Chat, `{"messages":[],"stop":["x"]}`, Responses},
|
||||
{Chat, `{"max_tokens":10,"messages":[],"tools":[{"type":"function","function":{"name":"x","parameters":{},"strict":true}}]}`, Anthropic},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if b, e := ConvertRequest(c.k, c.to, []byte(c.b), "m"); e == nil {
|
||||
t.Fatalf("accepted %s => %s", c.b, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestResponseMatrix(t *testing.T) {
|
||||
chat := []byte(`{"id":"r","model":"m","choices":[{"index":0,"message":{"role":"assistant","content":"working","tool_calls":[{"id":"c","type":"function","function":{"name":"read","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3}}}`)
|
||||
for _, from := range []Kind{Chat, Responses, Anthropic} {
|
||||
src, e := ConvertResponse(Chat, from, chat)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, to := range []Kind{Chat, Responses, Anthropic} {
|
||||
b, e := ConvertResponse(from, to, src)
|
||||
if e != nil {
|
||||
t.Fatal(from, to, e)
|
||||
}
|
||||
m, _ := decode(b)
|
||||
r, e := readResponse(to, m)
|
||||
if e != nil || len(r.parts) != 2 || r.finish != "tool" {
|
||||
t.Fatalf("%s %v", b, e)
|
||||
}
|
||||
u := ExtractUsage(to, b)
|
||||
if u.Input != 12 || u.Output != 4 || u.CacheRead != 3 || !u.Known {
|
||||
t.Fatal(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type flushBuffer struct {
|
||||
bytes.Buffer
|
||||
flushes int
|
||||
}
|
||||
|
||||
func (b *flushBuffer) Flush() { b.flushes++ }
|
||||
func chatStream() string {
|
||||
return strings.Join([]string{
|
||||
`data: {"id":"r","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"read","arguments":"{\"x\":"}},{"index":1,"id":"c2","type":"function","function":{"name":"write","arguments":"{"}}]}}]}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}},{"index":0,"function":{"arguments":"1}"}}]}}]}`,
|
||||
`data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
`data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":7}}`,
|
||||
`data: [DONE]`,
|
||||
}, "\n\n") + "\n\n"
|
||||
}
|
||||
func TestStreamMatrix(t *testing.T) {
|
||||
for _, from := range []Kind{Chat, Responses, Anthropic} {
|
||||
src := chatStream()
|
||||
if from != Chat {
|
||||
var b bytes.Buffer
|
||||
if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, from, "m"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
src = b.String()
|
||||
}
|
||||
for _, to := range []Kind{Chat, Responses, Anthropic} {
|
||||
t.Run(string(from)+"/"+string(to), func(t *testing.T) {
|
||||
var b flushBuffer
|
||||
u, e := Stream(context.Background(), &b, strings.NewReader(src), from, to, "m")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if u.Input != 10 || u.Output != 7 || !u.Known {
|
||||
t.Fatal(u)
|
||||
}
|
||||
if b.flushes < 3 {
|
||||
t.Fatal("not flushed")
|
||||
}
|
||||
if from == to && b.String() != src {
|
||||
t.Fatal("passthrough changed bytes")
|
||||
}
|
||||
if to == Responses {
|
||||
for _, event := range []string{"response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done", "response.completed"} {
|
||||
if !strings.Contains(b.String(), event) {
|
||||
t.Fatal("missing", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
if to == Anthropic && !strings.Contains(b.String(), "message_stop") {
|
||||
t.Fatal("missing stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestUsageCumulative(t *testing.T) {
|
||||
src := "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"output_tokens\":1,\"cache_read_input_tokens\":8,\"cache_creation_input_tokens\":2}}}\n\n" + "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":9}}\n\n"
|
||||
var b bytes.Buffer
|
||||
u, e := Stream(context.Background(), &b, strings.NewReader(src), Anthropic, Anthropic, "")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if u.Input != 15 || u.Output != 9 || u.CacheRead != 8 || u.CacheWrite != 2 {
|
||||
t.Fatal(u)
|
||||
}
|
||||
raw, e := decode(u.Raw)
|
||||
if e != nil || num(raw["input_tokens"]) != 5 || num(raw["output_tokens"]) != 9 || num(raw["cache_read_input_tokens"]) != 8 {
|
||||
t.Fatal(string(u.Raw), e)
|
||||
}
|
||||
}
|
||||
func TestRealtimeBeforeEOF(t *testing.T) {
|
||||
r, w := io.Pipe()
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
done := make(chan error, 1)
|
||||
out := &signalWriter{signal: make(chan struct{}, 1)}
|
||||
go func() { _, e := Stream(context.Background(), out, r, Chat, Responses, "m"); done <- e }()
|
||||
first, rest, _ := strings.Cut(chatStream(), "\n\n")
|
||||
if _, e := io.WriteString(w, first+"\n\n"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
select {
|
||||
case <-out.signal:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first delta buffered until EOF")
|
||||
}
|
||||
if _, e := io.WriteString(w, rest); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
select {
|
||||
case e := <-done:
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("terminal marker waits for EOF")
|
||||
}
|
||||
}
|
||||
|
||||
type signalWriter struct{ signal chan struct{} }
|
||||
|
||||
func (w *signalWriter) Write(p []byte) (int, error) {
|
||||
select {
|
||||
case w.signal <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
func TestUnknownUsageAndUniqueID(t *testing.T) {
|
||||
src := "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
|
||||
var previous string
|
||||
for i := 0; i < 2; i++ {
|
||||
var b bytes.Buffer
|
||||
u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Anthropic, "m")
|
||||
if e != nil || u.Known {
|
||||
t.Fatal(u, e)
|
||||
}
|
||||
line := strings.Split(b.String(), "\n")[1]
|
||||
m, e := decode([]byte(strings.TrimPrefix(line, "data: ")))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
msg := obj(m["message"])
|
||||
if obj(msg["usage"]) == nil {
|
||||
t.Fatal("missing initial usage")
|
||||
}
|
||||
id := str(msg["id"])
|
||||
if id == "" || id == previous {
|
||||
t.Fatal("nonunique id")
|
||||
}
|
||||
previous = id
|
||||
}
|
||||
}
|
||||
func TestStreamErrors(t *testing.T) {
|
||||
for _, src := range []string{"data: nope\n\n", "data: {\"error\":{\"message\":\"bad\"}}\n\n", strings.Replace(chatStream(), "data: [DONE]\n\n", "", 1), strings.Replace(chatStream(), `"arguments":"1}"`, `"arguments":"oops}"`, 1)} {
|
||||
var b bytes.Buffer
|
||||
if _, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Responses, "m"); e == nil {
|
||||
t.Fatal("accepted broken stream", src)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, e := Stream(ctx, io.Discard, strings.NewReader(chatStream()), Chat, Chat, ""); !errors.Is(e, context.Canceled) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := Stream(context.Background(), badWriter{}, strings.NewReader(chatStream()), Chat, Anthropic, ""); !errors.Is(e, io.ErrClosedPipe) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
|
||||
type badWriter struct{}
|
||||
|
||||
func (badWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
|
||||
func TestSSEMultilineAndCRLF(t *testing.T) {
|
||||
src := ": comment\r\n\r\ndata: {\r\ndata: \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":3}}\r\n\r\ndata: [DONE]\r\n\r\n"
|
||||
var b flushBuffer
|
||||
u, e := Stream(context.Background(), &b, strings.NewReader(src), Chat, Chat, "")
|
||||
if e != nil || u.Input != 2 || u.Output != 3 || b.String() != src {
|
||||
t.Fatal(u, e, b.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Recognized Anthropic thinking blocks are omitted only from converted output.
|
||||
func anthropicThinking(x object) (bool, error) {
|
||||
var names []string
|
||||
switch str(x["type"]) {
|
||||
case "thinking":
|
||||
names = []string{"thinking", "signature"}
|
||||
case "redacted_thinking":
|
||||
names = []string{"data"}
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
if err := fields(x, append([]string{"type"}, names...)...); err != nil {
|
||||
return true, err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := x[name].(string); !ok {
|
||||
return true, fmt.Errorf("invalid thinking %s", name)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func anthropicOutput(v any) (any, error) {
|
||||
content, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid Anthropic content")
|
||||
}
|
||||
clean := []any{}
|
||||
for _, block := range content {
|
||||
thinking, err := anthropicThinking(obj(block))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !thinking {
|
||||
clean = append(clean, block)
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
type thinkingBlock struct {
|
||||
kind string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *streamState) thinkingEvent(typ string, m object) (bool, error) {
|
||||
if typ == "message_delta" || typ == "message_stop" {
|
||||
for _, b := range s.thinking {
|
||||
if !b.closed {
|
||||
return true, fmt.Errorf("unfinished thinking block")
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if typ != "content_block_start" && typ != "content_block_delta" && typ != "content_block_stop" {
|
||||
return false, nil
|
||||
}
|
||||
index, err := responseIndex(m, "index")
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
b := s.thinking[index]
|
||||
if typ == "content_block_start" {
|
||||
if b != nil || s.source[index] != nil {
|
||||
return true, fmt.Errorf("duplicate content index")
|
||||
}
|
||||
x := obj(m["content_block"])
|
||||
thinking, err := anthropicThinking(x)
|
||||
if err != nil || !thinking {
|
||||
return thinking, err
|
||||
}
|
||||
if !s.started || s.finished {
|
||||
return true, fmt.Errorf("thinking outside active message")
|
||||
}
|
||||
if s.thinking == nil {
|
||||
s.thinking = map[int]*thinkingBlock{}
|
||||
}
|
||||
s.thinking[index] = &thinkingBlock{kind: str(x["type"])}
|
||||
return true, nil
|
||||
}
|
||||
if b == nil {
|
||||
return false, nil
|
||||
}
|
||||
if b.closed || s.finished {
|
||||
return true, fmt.Errorf("event after thinking block closed")
|
||||
}
|
||||
if typ == "content_block_stop" {
|
||||
b.closed = true
|
||||
return true, nil
|
||||
}
|
||||
d := obj(m["delta"])
|
||||
field := ""
|
||||
switch str(d["type"]) {
|
||||
case "thinking_delta":
|
||||
field = "thinking"
|
||||
case "signature_delta":
|
||||
field = "signature"
|
||||
}
|
||||
if b.kind != "thinking" || field == "" {
|
||||
return true, fmt.Errorf("invalid thinking delta")
|
||||
}
|
||||
if err := fields(d, "type", field); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if _, ok := d[field].(string); !ok {
|
||||
return true, fmt.Errorf("invalid thinking delta value")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// chatOutput omits output-only reasoning without exposing it as visible text.
|
||||
func chatOutput(x object) (object, error) {
|
||||
if v := x["reasoning_content"]; v != nil {
|
||||
if _, ok := v.(string); !ok {
|
||||
return nil, fmt.Errorf("reasoning_content must be a string or null")
|
||||
}
|
||||
}
|
||||
m := object{}
|
||||
for k, v := range x {
|
||||
if k != "reasoning_content" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Some Chat providers include streaming-style indices in complete tool calls.
|
||||
func chatResponseMessage(x object) (object, error) {
|
||||
m, err := chatOutput(x)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if calls, ok := x["tool_calls"].([]any); ok {
|
||||
clean := make([]any, len(calls))
|
||||
for i, v := range calls {
|
||||
call := obj(v)
|
||||
if _, exists := call["index"]; exists {
|
||||
index, err := responseIndex(call, "index")
|
||||
if err != nil || index != i {
|
||||
return nil, fmt.Errorf("invalid nonstream tool index")
|
||||
}
|
||||
}
|
||||
c := object{}
|
||||
for k, v := range call {
|
||||
if k != "index" {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
clean[i] = c
|
||||
}
|
||||
m["tool_calls"] = clean
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Phase describes output metadata, not message content or request semantics.
|
||||
func responseMessage(x object) object {
|
||||
m := object{}
|
||||
for k, v := range x {
|
||||
if k != "phase" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
if content, ok := x["content"].([]any); ok {
|
||||
clean := make([]any, len(content))
|
||||
for i, v := range content {
|
||||
p := obj(v)
|
||||
if logs, ok := p["logprobs"].([]any); ok && len(logs) == 0 && str(p["type"]) == "output_text" {
|
||||
q := object{}
|
||||
for k, v := range p {
|
||||
if k != "logprobs" {
|
||||
q[k] = v
|
||||
}
|
||||
}
|
||||
clean[i] = q
|
||||
} else {
|
||||
clean[i] = v
|
||||
}
|
||||
}
|
||||
m["content"] = clean
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func validateReasoning(x object) error {
|
||||
if err := fields(x, "type", "id", "status", "content", "summary", "encrypted_content"); err != nil {
|
||||
return err
|
||||
}
|
||||
for field, typ := range map[string]string{"content": "reasoning_text", "summary": "summary_text"} {
|
||||
if v, exists := x[field]; exists && v != nil {
|
||||
if _, ok := v.([]any); !ok {
|
||||
return fmt.Errorf("reasoning %s must be an array", field)
|
||||
}
|
||||
}
|
||||
for _, v := range arr(x[field]) {
|
||||
p := obj(v)
|
||||
if str(p["type"]) != typ {
|
||||
return fmt.Errorf("unsupported reasoning %s content", field)
|
||||
}
|
||||
if err := fields(p, "type", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type reasoningItem struct {
|
||||
id string
|
||||
closed bool
|
||||
parts map[string]bool
|
||||
}
|
||||
|
||||
func responseIndex(m object, field string) (int, error) {
|
||||
n, ok := m[field].(json.Number)
|
||||
i, err := n.Int64()
|
||||
if !ok || err != nil || i < 0 || int64(int(i)) != i {
|
||||
return 0, fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
return int(i), nil
|
||||
}
|
||||
|
||||
func (s *streamState) reasoningEvent(typ string, m object) (bool, error) {
|
||||
if typ == "response.completed" || typ == "response.incomplete" {
|
||||
seen := 0
|
||||
for i, v := range arr(obj(m["response"])["output"]) {
|
||||
x := obj(v)
|
||||
r := s.reasoning[i]
|
||||
if str(x["type"]) == "reasoning" {
|
||||
if r == nil || !r.closed || r.id != str(x["id"]) {
|
||||
return true, fmt.Errorf("final reasoning item disagrees with stream")
|
||||
}
|
||||
seen++
|
||||
} else if r != nil {
|
||||
return true, fmt.Errorf("final reasoning index changed type")
|
||||
}
|
||||
}
|
||||
if seen != len(s.reasoning) {
|
||||
return true, fmt.Errorf("final response missing reasoning item")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
known := false
|
||||
switch typ {
|
||||
case "response.reasoning_text.delta", "response.reasoning_text.done",
|
||||
"response.reasoning_summary_text.delta", "response.reasoning_summary_text.done",
|
||||
"response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
|
||||
known = true
|
||||
case "response.output_item.added", "response.output_item.done",
|
||||
"response.content_part.added", "response.content_part.done",
|
||||
"response.output_text.delta", "response.output_text.done",
|
||||
"response.function_call_arguments.delta", "response.function_call_arguments.done":
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
key, err := responseIndex(m, "output_index")
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
r := s.reasoning[key]
|
||||
x := obj(m["item"])
|
||||
if typ == "response.output_item.added" {
|
||||
if r != nil || s.source[key] != nil {
|
||||
return true, fmt.Errorf("duplicate output index %d", key)
|
||||
}
|
||||
if str(x["type"]) != "reasoning" {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateReasoning(x); err != nil {
|
||||
return true, err
|
||||
}
|
||||
if str(x["id"]) == "" {
|
||||
return true, fmt.Errorf("missing reasoning item id")
|
||||
}
|
||||
if s.reasoning == nil {
|
||||
s.reasoning = map[int]*reasoningItem{}
|
||||
}
|
||||
s.reasoning[key] = &reasoningItem{id: str(x["id"]), parts: map[string]bool{}}
|
||||
return true, nil
|
||||
}
|
||||
if r == nil {
|
||||
if known || str(x["type"]) == "reasoning" {
|
||||
return true, fmt.Errorf("reasoning event outside reasoning item")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if r.closed {
|
||||
return true, fmt.Errorf("event after reasoning item closed")
|
||||
}
|
||||
if typ == "response.output_item.done" {
|
||||
if str(x["type"]) != "reasoning" || str(x["id"]) != r.id {
|
||||
return true, fmt.Errorf("reasoning item identity mismatch")
|
||||
}
|
||||
if err := validateReasoning(x); err != nil {
|
||||
return true, err
|
||||
}
|
||||
for _, closed := range r.parts {
|
||||
if !closed {
|
||||
return true, fmt.Errorf("reasoning item has open parts")
|
||||
}
|
||||
}
|
||||
r.closed = true
|
||||
return true, nil
|
||||
}
|
||||
if str(m["item_id"]) != r.id {
|
||||
return true, fmt.Errorf("reasoning event item identity mismatch")
|
||||
}
|
||||
field, partType := "content_index", "reasoning_text"
|
||||
switch typ {
|
||||
case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done",
|
||||
"response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
|
||||
field, partType = "summary_index", "summary_text"
|
||||
case "response.reasoning_text.delta", "response.reasoning_text.done",
|
||||
"response.content_part.added", "response.content_part.done":
|
||||
default:
|
||||
return true, fmt.Errorf("non-reasoning event on reasoning item")
|
||||
}
|
||||
i, err := responseIndex(m, field)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
partKey := fmt.Sprintf("%s:%d", field, i)
|
||||
closed, exists := r.parts[partKey]
|
||||
switch typ {
|
||||
case "response.content_part.added", "response.reasoning_summary_part.added",
|
||||
"response.content_part.done", "response.reasoning_summary_part.done":
|
||||
p := obj(m["part"])
|
||||
if str(p["type"]) != partType {
|
||||
return true, fmt.Errorf("unsupported reasoning part")
|
||||
}
|
||||
if err := fields(p, "type", "text"); err != nil {
|
||||
return true, err
|
||||
}
|
||||
added := typ == "response.content_part.added" || typ == "response.reasoning_summary_part.added"
|
||||
if (added && exists) || (!added && (!exists || closed)) {
|
||||
return true, fmt.Errorf("invalid reasoning part lifecycle")
|
||||
}
|
||||
r.parts[partKey] = !added
|
||||
default:
|
||||
if !exists || closed {
|
||||
return true, fmt.Errorf("reasoning text outside active part")
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const reasoningJSON = `{"type":"reasoning","id":"r","content":[{"type":"reasoning_text","text":"SECRET"}],"summary":[{"type":"summary_text","text":"SUMMARY"}],"encrypted_content":"CIPHER"}`
|
||||
const visibleJSON = `{"type":"message","id":"m","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"OK","logprobs":[]}]}`
|
||||
|
||||
func reasoningSSE(events ...string) string {
|
||||
sep := string([]byte{10, 10})
|
||||
return "data: " + strings.Join(events, sep+"data: ") + sep
|
||||
}
|
||||
func TestReasoningConversions(t *testing.T) {
|
||||
for _, tool := range []bool{false, true} {
|
||||
for _, incomplete := range []bool{false, true} {
|
||||
visible, initial, delta := visibleJSON, `{"type":"message","content":[]}`, `{"type":"response.output_text.delta","output_index":1,"delta":"OK"}`
|
||||
if tool {
|
||||
visible = `{"type":"function_call","call_id":"c","name":"lookup","arguments":"{}"}`
|
||||
initial = `{"type":"function_call","call_id":"c","name":"lookup","arguments":""}`
|
||||
delta = `{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{}"}`
|
||||
}
|
||||
status, details := "completed", ""
|
||||
if incomplete {
|
||||
status = "incomplete"
|
||||
details = `,"incomplete_details":{"reason":"max_output_tokens"}`
|
||||
}
|
||||
body := `{"id":"res","model":"m","status":"` + status + `","output":[` + reasoningJSON + `,` + visible + `],"usage":{"input_tokens":9,"output_tokens":7,"output_tokens_details":{"reasoning_tokens":5}}` + details + `}`
|
||||
src := reasoningSSE(`{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"r"}}`,
|
||||
`{"type":"response.content_part.added","output_index":0,"item_id":"r","content_index":0,"part":{"type":"reasoning_text","text":""}}`,
|
||||
`{"type":"response.reasoning_text.delta","output_index":0,"item_id":"r","content_index":0,"delta":"SECRET"}`,
|
||||
`{"type":"response.reasoning_text.done","output_index":0,"item_id":"r","content_index":0,"text":"SECRET"}`,
|
||||
`{"type":"response.content_part.done","output_index":0,"item_id":"r","content_index":0,"part":{"type":"reasoning_text","text":"SECRET"}}`,
|
||||
`{"type":"response.reasoning_summary_part.added","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":""}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","output_index":0,"item_id":"r","summary_index":0,"delta":"SUMMARY"}`,
|
||||
`{"type":"response.reasoning_summary_text.done","output_index":0,"item_id":"r","summary_index":0,"text":"SUMMARY"}`,
|
||||
`{"type":"response.reasoning_summary_part.done","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":"SUMMARY"}}`,
|
||||
`{"type":"response.output_item.done","output_index":0,"item":`+reasoningJSON+`}`,
|
||||
`{"type":"response.output_item.added","output_index":1,"item":`+initial+`}`, delta,
|
||||
`{"type":"response.output_item.done","output_index":1,"item":`+visible+`}`,
|
||||
`{"type":"response.`+status+`","response":`+body+`}`)
|
||||
for _, target := range []Kind{Chat, Anthropic, Responses} {
|
||||
out, err := ConvertResponse(Responses, target, []byte(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
u, err := Stream(context.Background(), &b, strings.NewReader(src), Responses, target, "m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.Known || u.Input != 9 || u.Output != 7 || !bytes.Contains(u.Raw, []byte("reasoning_tokens")) {
|
||||
t.Fatalf("usage: %+v", u)
|
||||
}
|
||||
if target == Responses {
|
||||
if string(out) != body || b.String() != src {
|
||||
t.Fatal("changed same-protocol bytes")
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, result := range []string{string(out), b.String()} {
|
||||
for _, secret := range []string{"SECRET", "SUMMARY", "CIPHER", "signature", "thinking", "phase"} {
|
||||
if strings.Contains(result, secret) {
|
||||
t.Fatal("leaked " + secret)
|
||||
}
|
||||
}
|
||||
want := "OK"
|
||||
if tool {
|
||||
want = "lookup"
|
||||
}
|
||||
if !strings.Contains(result, want) {
|
||||
t.Fatal("lost visible content")
|
||||
}
|
||||
finish := "stop"
|
||||
if target == Anthropic {
|
||||
finish = "end_turn"
|
||||
}
|
||||
if tool {
|
||||
finish = "tool_calls"
|
||||
if target == Anthropic {
|
||||
finish = "tool_use"
|
||||
}
|
||||
}
|
||||
if incomplete {
|
||||
finish = "length"
|
||||
if target == Anthropic {
|
||||
finish = "max_tokens"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(result, finish) {
|
||||
t.Fatal("lost finish " + finish)
|
||||
}
|
||||
}
|
||||
if target == Chat && !strings.Contains(b.String(), "[DONE]") {
|
||||
t.Fatal("missing terminal")
|
||||
}
|
||||
if target == Anthropic && (!strings.Contains(b.String(), "message_stop") || !strings.Contains(b.String(), `"index":0`)) {
|
||||
t.Fatal("terminal/index")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningRejectsInvalidStreams(t *testing.T) {
|
||||
start := `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"r"}}`
|
||||
part := `{"type":"response.reasoning_summary_part.added","output_index":0,"item_id":"r","summary_index":0,"part":{"type":"summary_text","text":""}}`
|
||||
done := `{"type":"response.output_item.done","output_index":0,"item":` + reasoningJSON + `}`
|
||||
for name, events := range map[string][]string{
|
||||
"duplicate": {start, start}, "part before item": {part}, "open part": {start, part, done}, "closed item": {start, done, part},
|
||||
"wrong id": {start, strings.Replace(part, `"item_id":"r"`, `"item_id":"x"`, 1)},
|
||||
"unknown": {start, `{"type":"response.reasoning_unknown.delta","output_index":0}`},
|
||||
"text collision": {start, `{"type":"response.output_text.delta","output_index":0,"item_id":"r","delta":"lost"}`},
|
||||
"tool collision": {start, `{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"r","delta":"{}"}`},
|
||||
"unknown part": {start, strings.Replace(part, "summary_text", "output_text", 1)},
|
||||
"negative": {strings.Replace(start, `"output_index":0`, `"output_index":-1`, 1)},
|
||||
"fractional": {strings.Replace(start, `"output_index":0`, `"output_index":0.5`, 1)},
|
||||
"final missing": {start, done, `{"type":"response.completed","response":{"status":"completed","output":[]}}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
_, err := Stream(context.Background(), &b, strings.NewReader(reasoningSSE(events...)), Responses, Chat, "")
|
||||
if err == nil || strings.Contains(err.Error(), "truncat") {
|
||||
t.Fatalf("expected validation error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, bad := range []string{strings.Replace(reasoningJSON, "reasoning_text", "output_text", 1), strings.Replace(reasoningJSON, "summary_text", "unknown", 1), `{"type":"reasoning","summary":{}}`, strings.Replace(visibleJSON, `"logprobs":[]`, `"logprobs":[{}]`, 1), strings.Replace(visibleJSON, `"phase":`, `"unknown":`, 1)} {
|
||||
if _, err := ConvertResponse(Responses, Chat, []byte(`{"status":"completed","output":[`+bad+`]}`)); err == nil {
|
||||
t.Fatal("accepted invalid output")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxStreamBytes = 16 << 20
|
||||
const maxStreamBlocks = 4096
|
||||
const maxStreamUsageBytes = 16 << 10
|
||||
const maxPassthroughBytes = 64 << 20
|
||||
|
||||
type streamBlock struct {
|
||||
part
|
||||
content, identity, functionName strings.Builder
|
||||
index int
|
||||
closed bool
|
||||
emitted bool
|
||||
sent int
|
||||
}
|
||||
type streamState struct {
|
||||
thinking map[int]*thinkingBlock
|
||||
ctx context.Context
|
||||
w io.Writer
|
||||
from, to Kind
|
||||
r response
|
||||
blocks []*streamBlock
|
||||
source map[int]*streamBlock
|
||||
reasoning map[int]*reasoningItem
|
||||
started, finished, terminal bool
|
||||
sequence int
|
||||
retained, blockCount int
|
||||
byteLimit, blockLimit int
|
||||
}
|
||||
|
||||
func (s *streamState) retain(n int) error {
|
||||
if n > s.byteLimit-s.retained {
|
||||
return fmt.Errorf("stream retained content exceeds %d bytes", s.byteLimit)
|
||||
}
|
||||
s.retained += n
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) reserveBlock() error {
|
||||
if s.blockCount >= s.blockLimit {
|
||||
return fmt.Errorf("stream exceeds %d blocks", s.blockLimit)
|
||||
}
|
||||
s.blockCount++
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFlush(w io.Writer, b []byte) error {
|
||||
n, e := w.Write(b)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if n != len(b) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
if f, ok := w.(interface{ Flush() error }); ok {
|
||||
return f.Flush()
|
||||
}
|
||||
if f, ok := w.(interface{ Flush() }); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) event(name string, m object) error {
|
||||
if e := s.ctx.Err(); e != nil {
|
||||
return e
|
||||
}
|
||||
if s.to == Responses {
|
||||
m["sequence_number"] = s.sequence
|
||||
s.sequence++
|
||||
}
|
||||
b, e := json.Marshal(m)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
prefix := ""
|
||||
if name != "" {
|
||||
prefix = "event: " + name + "\n"
|
||||
}
|
||||
return writeFlush(s.w, []byte(prefix+"data: "+string(b)+"\n\n"))
|
||||
}
|
||||
func (s *streamState) typed(name string, m object) error { m["type"] = name; return s.event(name, m) }
|
||||
func (s *streamState) chat(delta object, finish any) error {
|
||||
return s.event("", object{"id": s.r.id, "object": "chat.completion.chunk", "created": 0, "model": s.r.model, "choices": []any{object{"index": 0, "delta": delta, "finish_reason": finish}}})
|
||||
}
|
||||
func (s *streamState) start() error {
|
||||
if s.started {
|
||||
return nil
|
||||
}
|
||||
s.started = true
|
||||
if s.r.id == "" {
|
||||
s.r.id = newID()
|
||||
}
|
||||
switch s.to {
|
||||
case Chat:
|
||||
return s.chat(object{"role": "assistant", "content": ""}, nil)
|
||||
case Anthropic:
|
||||
m := object{"id": s.r.id, "type": "message", "role": "assistant", "model": s.r.model, "content": []any{}, "stop_reason": nil, "stop_sequence": nil}
|
||||
m["usage"] = usageJSON(Anthropic, s.r.usage)
|
||||
return s.typed("message_start", object{"message": m})
|
||||
case Responses:
|
||||
r := object{"id": s.r.id, "object": "response", "created_at": time.Now().Unix(), "model": s.r.model, "status": "in_progress", "output": []any{}, "error": nil, "incomplete_details": nil, "usage": nil}
|
||||
if e := s.typed("response.created", object{"response": r}); e != nil {
|
||||
return e
|
||||
}
|
||||
return s.typed("response.in_progress", object{"response": r})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) itemID(b *streamBlock) string {
|
||||
if b.typ == "call" {
|
||||
return fmt.Sprintf("fc_%s_%d", s.r.id, b.index)
|
||||
}
|
||||
return fmt.Sprintf("msg_%s_%d", s.r.id, b.index)
|
||||
}
|
||||
func (s *streamState) item(b *streamBlock, done bool) object {
|
||||
status := "in_progress"
|
||||
if done {
|
||||
status = "completed"
|
||||
}
|
||||
if b.typ == "call" {
|
||||
args := ""
|
||||
if done {
|
||||
args = b.args
|
||||
}
|
||||
return object{"id": s.itemID(b), "type": "function_call", "call_id": b.id, "name": b.name, "arguments": args, "status": status}
|
||||
}
|
||||
content := []any{}
|
||||
if done {
|
||||
content = append(content, object{"type": "output_text", "text": b.text, "annotations": []any{}})
|
||||
}
|
||||
return object{"id": s.itemID(b), "type": "message", "role": "assistant", "status": status, "content": content}
|
||||
}
|
||||
func (s *streamState) open(b *streamBlock) error {
|
||||
if b.emitted {
|
||||
return nil
|
||||
}
|
||||
if b.typ == "call" && (b.id == "" || b.name == "") {
|
||||
return nil
|
||||
}
|
||||
if e := s.start(); e != nil {
|
||||
return e
|
||||
}
|
||||
b.emitted = true
|
||||
switch s.to {
|
||||
case Chat:
|
||||
if b.typ == "call" {
|
||||
return s.chat(object{"tool_calls": []any{object{"index": s.toolIndex(b), "id": b.id, "type": "function", "function": object{"name": b.name, "arguments": ""}}}}, nil)
|
||||
}
|
||||
case Anthropic:
|
||||
c := object{"type": "text", "text": ""}
|
||||
if b.typ == "call" {
|
||||
c = object{"type": "tool_use", "id": b.id, "name": b.name, "input": object{}}
|
||||
}
|
||||
return s.typed("content_block_start", object{"index": b.index, "content_block": c})
|
||||
case Responses:
|
||||
if e := s.typed("response.output_item.added", object{"output_index": b.index, "item": s.item(b, false)}); e != nil {
|
||||
return e
|
||||
}
|
||||
if b.typ == "text" {
|
||||
return s.typed("response.content_part.added", object{"output_index": b.index, "item_id": s.itemID(b), "content_index": 0, "part": object{"type": "output_text", "text": "", "annotations": []any{}}})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) toolIndex(b *streamBlock) int {
|
||||
i := 0
|
||||
for _, x := range s.blocks {
|
||||
if x == b {
|
||||
return i
|
||||
}
|
||||
if x.typ == "call" {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
func (s *streamState) add(key int, p part) (*streamBlock, error) {
|
||||
if s.finished {
|
||||
return nil, fmt.Errorf("content after finish")
|
||||
}
|
||||
if _, ok := s.source[key]; ok {
|
||||
return nil, fmt.Errorf("duplicate content index %d", key)
|
||||
}
|
||||
if e := s.reserveBlock(); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e := s.retain(len(p.text) + len(p.args) + len(p.id) + len(p.name)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
b := &streamBlock{part: p, index: len(s.blocks)}
|
||||
b.content.WriteString(p.text)
|
||||
b.content.WriteString(p.args)
|
||||
b.identity.WriteString(p.id)
|
||||
b.functionName.WriteString(p.name)
|
||||
s.blocks = append(s.blocks, b)
|
||||
s.source[key] = b
|
||||
return b, s.open(b)
|
||||
}
|
||||
func (s *streamState) delta(b *streamBlock, text string) error {
|
||||
if b == nil || b.closed || s.finished {
|
||||
return fmt.Errorf("delta outside active content block")
|
||||
}
|
||||
if e := s.retain(len(text)); e != nil {
|
||||
return e
|
||||
}
|
||||
b.content.WriteString(text)
|
||||
// Builder.String shares storage without copying accumulated content.
|
||||
if b.typ == "text" {
|
||||
b.text = b.content.String()
|
||||
} else {
|
||||
b.args = b.content.String()
|
||||
}
|
||||
if e := s.open(b); e != nil {
|
||||
return e
|
||||
}
|
||||
if !b.emitted {
|
||||
return nil
|
||||
}
|
||||
whole := b.text
|
||||
if b.typ == "call" {
|
||||
whole = b.args
|
||||
}
|
||||
text = whole[b.sent:]
|
||||
b.sent = len(whole)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
switch s.to {
|
||||
case Chat:
|
||||
if b.typ == "text" {
|
||||
return s.chat(object{"content": text}, nil)
|
||||
}
|
||||
return s.chat(object{"tool_calls": []any{object{"index": s.toolIndex(b), "function": object{"arguments": text}}}}, nil)
|
||||
case Anthropic:
|
||||
d := object{"type": "text_delta", "text": text}
|
||||
if b.typ == "call" {
|
||||
d = object{"type": "input_json_delta", "partial_json": text}
|
||||
}
|
||||
return s.typed("content_block_delta", object{"index": b.index, "delta": d})
|
||||
case Responses:
|
||||
name := "response.output_text.delta"
|
||||
m := object{"output_index": b.index, "item_id": s.itemID(b), "delta": text}
|
||||
if b.typ == "call" {
|
||||
name = "response.function_call_arguments.delta"
|
||||
} else {
|
||||
m["content_index"] = 0
|
||||
}
|
||||
return s.typed(name, m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) close(b *streamBlock) error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("unknown content block")
|
||||
}
|
||||
if b.closed {
|
||||
return nil
|
||||
}
|
||||
if b.typ == "call" {
|
||||
if b.id == "" || b.name == "" {
|
||||
return fmt.Errorf("incomplete tool identity")
|
||||
}
|
||||
if b.args == "" {
|
||||
if e := s.delta(b, "{}"); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if _, e := decode([]byte(b.args)); e != nil {
|
||||
return fmt.Errorf("invalid streamed tool arguments: %w", e)
|
||||
}
|
||||
}
|
||||
if e := s.open(b); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := s.delta(b, ""); e != nil {
|
||||
return e
|
||||
}
|
||||
b.closed = true
|
||||
switch s.to {
|
||||
case Anthropic:
|
||||
return s.typed("content_block_stop", object{"index": b.index})
|
||||
case Responses:
|
||||
m := object{"output_index": b.index, "item_id": s.itemID(b)}
|
||||
name := "response.function_call_arguments.done"
|
||||
if b.typ == "call" {
|
||||
m["arguments"] = b.args
|
||||
} else {
|
||||
name = "response.output_text.done"
|
||||
m["text"] = b.text
|
||||
m["content_index"] = 0
|
||||
}
|
||||
if e := s.typed(name, m); e != nil {
|
||||
return e
|
||||
}
|
||||
if b.typ == "text" {
|
||||
if e := s.typed("response.content_part.done", object{"output_index": b.index, "item_id": s.itemID(b), "content_index": 0, "part": object{"type": "output_text", "text": b.text, "annotations": []any{}}}); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return s.typed("response.output_item.done", object{"output_index": b.index, "item": s.item(b, true)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) finish(reason string) error {
|
||||
if s.finished {
|
||||
return fmt.Errorf("duplicate finish")
|
||||
}
|
||||
if e := s.start(); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, b := range s.blocks {
|
||||
if e := s.close(b); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
s.r.finish = reason
|
||||
s.finished = true
|
||||
return nil
|
||||
}
|
||||
func (s *streamState) end() error {
|
||||
if !s.finished {
|
||||
return fmt.Errorf("stream ended without finish reason")
|
||||
}
|
||||
switch s.to {
|
||||
case Chat:
|
||||
if e := s.chat(object{}, wireFinish(Chat, s.r.finish)); e != nil {
|
||||
return e
|
||||
}
|
||||
if s.r.usage.Known {
|
||||
if e := s.event("", object{"id": s.r.id, "object": "chat.completion.chunk", "created": 0, "model": s.r.model, "choices": []any{}, "usage": usageJSON(Chat, s.r.usage)}); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return writeFlush(s.w, []byte("data: [DONE]\n\n"))
|
||||
case Anthropic:
|
||||
m := object{"delta": object{"stop_reason": wireFinish(Anthropic, s.r.finish), "stop_sequence": nil}}
|
||||
if s.r.usage.Known {
|
||||
m["usage"] = usageJSON(Anthropic, s.r.usage)
|
||||
}
|
||||
if e := s.typed("message_delta", m); e != nil {
|
||||
return e
|
||||
}
|
||||
return s.typed("message_stop", object{})
|
||||
case Responses:
|
||||
r := s.r
|
||||
for _, b := range s.blocks {
|
||||
r.parts = append(r.parts, b.part)
|
||||
}
|
||||
m, e := writeResponse(Responses, r)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
items := []any{}
|
||||
for _, b := range s.blocks {
|
||||
items = append(items, s.item(b, true))
|
||||
}
|
||||
m["output"] = items
|
||||
name := "response.completed"
|
||||
if r.finish == "length" {
|
||||
name = "response.incomplete"
|
||||
}
|
||||
return s.typed(name, object{"response": m})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func mergeUsage(k Kind, old Usage, m object) (Usage, error) {
|
||||
if r := obj(m["response"]); r != nil {
|
||||
m = r
|
||||
}
|
||||
if r := obj(m["message"]); r != nil {
|
||||
m = r
|
||||
}
|
||||
u := obj(m["usage"])
|
||||
if u == nil {
|
||||
return old, nil
|
||||
}
|
||||
raw, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
return old, err
|
||||
}
|
||||
if len(raw) > maxStreamUsageBytes {
|
||||
return old, fmt.Errorf("stream usage exceeds %d bytes", maxStreamUsageBytes)
|
||||
}
|
||||
if len(old.Raw) > 0 {
|
||||
prior, e := decode(old.Raw)
|
||||
if e != nil {
|
||||
return old, e
|
||||
}
|
||||
for key, v := range u {
|
||||
prior[key] = v
|
||||
}
|
||||
u = prior
|
||||
}
|
||||
n := usage(k, u)
|
||||
if len(n.Raw) > maxStreamUsageBytes {
|
||||
return old, fmt.Errorf("stream usage exceeds %d bytes", maxStreamUsageBytes)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func streamError(m object) error {
|
||||
if m["error"] != nil {
|
||||
return fmt.Errorf("upstream stream error: %v", m["error"])
|
||||
}
|
||||
switch str(m["type"]) {
|
||||
case "error", "response.failed":
|
||||
return fmt.Errorf("upstream stream error: %v", m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *streamState) consume(m object, event string) error {
|
||||
if e := streamError(m); e != nil {
|
||||
return e
|
||||
}
|
||||
u, err := mergeUsage(s.from, s.r.usage, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.r.usage = u
|
||||
if s.from == Chat {
|
||||
if id := str(m["id"]); id != "" && !s.started {
|
||||
s.r.id = id
|
||||
}
|
||||
if s.r.model == "" {
|
||||
s.r.model = str(m["model"])
|
||||
}
|
||||
cs := arr(m["choices"])
|
||||
if len(cs) > 1 {
|
||||
return fmt.Errorf("multiple streaming choices unsupported")
|
||||
}
|
||||
for _, v := range cs {
|
||||
c := obj(v)
|
||||
if num(c["index"]) != 0 {
|
||||
return fmt.Errorf("nonzero choice index unsupported")
|
||||
}
|
||||
if c["logprobs"] != nil {
|
||||
return fmt.Errorf("logprobs unsupported")
|
||||
}
|
||||
d, e := chatOutput(obj(c["delta"]))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e := fields(d, "role", "content", "tool_calls"); e != nil {
|
||||
return e
|
||||
}
|
||||
if role := str(d["role"]); role != "" && role != "assistant" {
|
||||
return fmt.Errorf("invalid delta role")
|
||||
}
|
||||
if e := s.start(); e != nil {
|
||||
return e
|
||||
}
|
||||
if text, ok := d["content"]; ok && text != nil {
|
||||
t, ok := text.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("non-text streamed content")
|
||||
}
|
||||
b := s.source[-1]
|
||||
if b == nil {
|
||||
var e error
|
||||
b, e = s.add(-1, part{typ: "text"})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if e := s.delta(b, t); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
for _, v := range arr(d["tool_calls"]) {
|
||||
t := obj(v)
|
||||
if e := fields(t, "index", "id", "type", "function"); e != nil {
|
||||
return e
|
||||
}
|
||||
idx, ok := t["index"]
|
||||
if !ok || num(idx) < 0 {
|
||||
return fmt.Errorf("invalid tool index")
|
||||
}
|
||||
if typ := str(t["type"]); typ != "" && typ != "function" {
|
||||
return fmt.Errorf("unsupported streamed tool")
|
||||
}
|
||||
f := obj(t["function"])
|
||||
if e := fields(f, "name", "arguments"); e != nil {
|
||||
return e
|
||||
}
|
||||
key := int(num(idx))
|
||||
b := s.source[key]
|
||||
if b == nil {
|
||||
var e error
|
||||
b, e = s.add(key, part{typ: "call"})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if b.emitted && (str(t["id"]) != "" || str(f["name"]) != "") {
|
||||
return fmt.Errorf("fragmented tool identity after arguments unsupported")
|
||||
}
|
||||
if e := s.retain(len(str(t["id"])) + len(str(f["name"]))); e != nil {
|
||||
return e
|
||||
}
|
||||
b.identity.WriteString(str(t["id"]))
|
||||
b.functionName.WriteString(str(f["name"]))
|
||||
b.id = b.identity.String()
|
||||
b.name = b.functionName.String()
|
||||
if e := s.delta(b, str(f["arguments"])); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if reason := str(c["finish_reason"]); reason != "" {
|
||||
f, e := finishReason(Chat, reason)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = s.finish(f); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
typ := str(m["type"])
|
||||
if typ == "" {
|
||||
typ = event
|
||||
}
|
||||
if event != "" && typ != event {
|
||||
return fmt.Errorf("SSE event/type mismatch")
|
||||
}
|
||||
// Discarded blocks still retain lifecycle bookkeeping in separate maps.
|
||||
if (s.from == Anthropic && typ == "content_block_start" && (str(obj(m["content_block"])["type"]) == "thinking" || str(obj(m["content_block"])["type"]) == "redacted_thinking")) ||
|
||||
(s.from == Responses && typ == "response.output_item.added" && str(obj(m["item"])["type"]) == "reasoning") ||
|
||||
(s.from == Responses && (typ == "response.content_part.added" || typ == "response.reasoning_summary_part.added") && s.reasoning[int(num(m["output_index"]))] != nil) {
|
||||
if e := s.reserveBlock(); e != nil {
|
||||
return e
|
||||
}
|
||||
if typ == "response.output_item.added" {
|
||||
if e := s.retain(len(str(obj(m["item"])["id"]))); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.from == Anthropic {
|
||||
if handled, err := s.thinkingEvent(typ, m); handled || err != nil {
|
||||
return err
|
||||
}
|
||||
switch typ {
|
||||
case "ping":
|
||||
return nil
|
||||
case "message_start":
|
||||
if s.started {
|
||||
return fmt.Errorf("duplicate message_start")
|
||||
}
|
||||
x := obj(m["message"])
|
||||
s.r.id = str(x["id"])
|
||||
if s.r.model == "" {
|
||||
s.r.model = str(x["model"])
|
||||
}
|
||||
if len(arr(x["content"])) != 0 {
|
||||
return fmt.Errorf("nonempty message_start content")
|
||||
}
|
||||
return s.start()
|
||||
case "content_block_start":
|
||||
x := obj(m["content_block"])
|
||||
p := part{typ: "text"}
|
||||
initial := ""
|
||||
switch str(x["type"]) {
|
||||
case "text":
|
||||
if e := fields(x, "type", "text"); e != nil {
|
||||
return e
|
||||
}
|
||||
initial = str(x["text"])
|
||||
case "tool_use":
|
||||
if e := fields(x, "type", "id", "name", "input"); e != nil {
|
||||
return e
|
||||
}
|
||||
p = part{typ: "call", id: str(x["id"]), name: str(x["name"])}
|
||||
if obj(x["input"]) == nil {
|
||||
return fmt.Errorf("tool input must be an object")
|
||||
}
|
||||
if len(obj(x["input"])) > 0 {
|
||||
b, e := json.Marshal(x["input"])
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
initial = string(b)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported streamed block %q", str(x["type"]))
|
||||
}
|
||||
b, e := s.add(int(num(m["index"])), p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.delta(b, initial)
|
||||
case "content_block_delta":
|
||||
b := s.source[int(num(m["index"]))]
|
||||
d := obj(m["delta"])
|
||||
switch str(d["type"]) {
|
||||
case "text_delta":
|
||||
if b == nil || b.typ != "text" {
|
||||
return fmt.Errorf("text delta block mismatch")
|
||||
}
|
||||
return s.delta(b, str(d["text"]))
|
||||
case "input_json_delta":
|
||||
if b == nil || b.typ != "call" {
|
||||
return fmt.Errorf("tool delta block mismatch")
|
||||
}
|
||||
return s.delta(b, str(d["partial_json"]))
|
||||
default:
|
||||
return fmt.Errorf("unsupported delta %q", str(d["type"]))
|
||||
}
|
||||
case "content_block_stop":
|
||||
return s.close(s.source[int(num(m["index"]))])
|
||||
case "message_delta":
|
||||
d := obj(m["delta"])
|
||||
if e := fields(d, "stop_reason", "stop_sequence"); e != nil {
|
||||
return e
|
||||
}
|
||||
if d["stop_sequence"] != nil {
|
||||
return fmt.Errorf("stop sequence cannot be preserved")
|
||||
}
|
||||
f, e := finishReason(Anthropic, str(d["stop_reason"]))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.finish(f)
|
||||
case "message_stop":
|
||||
s.terminal = true
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported Anthropic event %q", typ)
|
||||
}
|
||||
}
|
||||
if handled, err := s.reasoningEvent(typ, m); handled || err != nil {
|
||||
return err
|
||||
}
|
||||
switch typ {
|
||||
case "response.created", "response.in_progress":
|
||||
x := obj(m["response"])
|
||||
if !s.started {
|
||||
s.r.id = str(x["id"])
|
||||
if s.r.model == "" {
|
||||
s.r.model = str(x["model"])
|
||||
}
|
||||
}
|
||||
return s.start()
|
||||
case "response.output_item.added":
|
||||
x := obj(m["item"])
|
||||
key := int(num(m["output_index"]))
|
||||
p := part{typ: "text"}
|
||||
switch str(x["type"]) {
|
||||
case "message":
|
||||
if len(arr(x["content"])) > 0 {
|
||||
return fmt.Errorf("nonempty initial response content")
|
||||
}
|
||||
case "function_call":
|
||||
if _, ok := x["arguments"].(string); !ok {
|
||||
return fmt.Errorf("function arguments must be a string")
|
||||
}
|
||||
p = part{typ: "call", id: str(x["call_id"]), name: str(x["name"])}
|
||||
default:
|
||||
return fmt.Errorf("unsupported output item %q", str(x["type"]))
|
||||
}
|
||||
b, e := s.add(key, p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if p.typ == "call" {
|
||||
return s.delta(b, str(x["arguments"]))
|
||||
}
|
||||
return nil
|
||||
case "response.content_part.added":
|
||||
p := obj(m["part"])
|
||||
if str(p["type"]) != "output_text" || num(m["content_index"]) != 0 {
|
||||
return fmt.Errorf("unsupported response content part")
|
||||
}
|
||||
if len(arr(p["annotations"])) > 0 {
|
||||
return fmt.Errorf("annotations unsupported")
|
||||
}
|
||||
return s.delta(s.source[int(num(m["output_index"]))], str(p["text"]))
|
||||
case "response.output_text.delta", "response.function_call_arguments.delta":
|
||||
b := s.source[int(num(m["output_index"]))]
|
||||
if b == nil {
|
||||
return fmt.Errorf("delta before output item")
|
||||
}
|
||||
if (typ == "response.output_text.delta") != (b.typ == "text") || num(m["content_index"]) != 0 {
|
||||
return fmt.Errorf("response delta block mismatch")
|
||||
}
|
||||
return s.delta(b, str(m["delta"]))
|
||||
case "response.output_text.done", "response.function_call_arguments.done":
|
||||
b := s.source[int(num(m["output_index"]))]
|
||||
if b == nil {
|
||||
return fmt.Errorf("done before output item")
|
||||
}
|
||||
full := str(m["text"])
|
||||
got := b.text
|
||||
if b.typ == "call" {
|
||||
full = str(m["arguments"])
|
||||
got = b.args
|
||||
}
|
||||
if full != got {
|
||||
return fmt.Errorf("final content disagrees with deltas")
|
||||
}
|
||||
return nil
|
||||
case "response.content_part.done":
|
||||
p := obj(m["part"])
|
||||
if str(p["type"]) != "output_text" || len(arr(p["annotations"])) > 0 || num(m["content_index"]) != 0 {
|
||||
return fmt.Errorf("unsupported content part")
|
||||
}
|
||||
return nil
|
||||
case "response.output_item.done":
|
||||
return s.close(s.source[int(num(m["output_index"]))])
|
||||
case "response.completed", "response.incomplete":
|
||||
r, e := readResponse(Responses, obj(m["response"]))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(r.parts) != len(s.blocks) {
|
||||
return fmt.Errorf("final response disagrees with streamed items")
|
||||
}
|
||||
for i, p := range r.parts {
|
||||
b := s.blocks[i]
|
||||
if p.typ != b.typ || p.text != b.text || p.args != b.args || p.id != b.id || p.name != b.name {
|
||||
return fmt.Errorf("final response disagrees with streamed content")
|
||||
}
|
||||
}
|
||||
if e = s.finish(r.finish); e != nil {
|
||||
return e
|
||||
}
|
||||
s.terminal = true
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported Responses event %q", typ)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream does not own r. A caller must cancel or close a blocked upstream read.
|
||||
func Stream(ctx context.Context, w io.Writer, r io.Reader, from, to Kind, model string) (Usage, error) {
|
||||
return streamWithLimits(ctx, w, r, from, to, model, maxStreamBytes, maxStreamBlocks, maxPassthroughBytes)
|
||||
}
|
||||
|
||||
func readSSELine(ctx context.Context, br *bufio.Reader, remaining int) (string, error) {
|
||||
var line strings.Builder
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fragment, err := br.ReadSlice('\n')
|
||||
if len(fragment) > remaining-line.Len() {
|
||||
return "", fmt.Errorf("SSE line/event exceeds size limit")
|
||||
}
|
||||
line.Write(fragment)
|
||||
if err != bufio.ErrBufferFull {
|
||||
return line.String(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamWithLimits(ctx context.Context, w io.Writer, r io.Reader, from, to Kind, model string, byteLimit, blockLimit, totalLimit int) (Usage, error) {
|
||||
if !valid(from) || !valid(to) {
|
||||
return Usage{}, fmt.Errorf("unknown protocol")
|
||||
}
|
||||
s := &streamState{ctx: ctx, w: w, from: from, to: to, r: response{model: model}, source: map[int]*streamBlock{}, byteLimit: byteLimit, blockLimit: blockLimit}
|
||||
br := bufio.NewReader(r)
|
||||
data := []string{}
|
||||
event := ""
|
||||
size := 0
|
||||
total := 0
|
||||
dispatch := func() error {
|
||||
if len(data) == 0 {
|
||||
event = ""
|
||||
return nil
|
||||
}
|
||||
payload := strings.Join(data, "\n")
|
||||
data = nil
|
||||
ev := event
|
||||
event = ""
|
||||
size = 0
|
||||
if payload == "[DONE]" {
|
||||
if from != Chat {
|
||||
return fmt.Errorf("unexpected DONE marker")
|
||||
}
|
||||
s.terminal = true
|
||||
return nil
|
||||
}
|
||||
m, e := decode([]byte(payload))
|
||||
if e != nil {
|
||||
return fmt.Errorf("invalid SSE JSON: %w", e)
|
||||
}
|
||||
if e = streamError(m); e != nil {
|
||||
return e
|
||||
}
|
||||
if from == to {
|
||||
u, err := mergeUsage(from, s.r.usage, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.r.usage = u
|
||||
switch str(m["type"]) {
|
||||
case "message_stop", "response.completed", "response.incomplete":
|
||||
s.terminal = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.terminal {
|
||||
return fmt.Errorf("data after terminal event")
|
||||
}
|
||||
return s.consume(m, ev)
|
||||
}
|
||||
for {
|
||||
if e := ctx.Err(); e != nil {
|
||||
return s.r.usage, e
|
||||
}
|
||||
line, e := readSSELine(ctx, br, byteLimit-size)
|
||||
if e != nil && e != io.EOF {
|
||||
return s.r.usage, e
|
||||
}
|
||||
if len(line) > 0 {
|
||||
if from == to {
|
||||
if total+len(line) > totalLimit {
|
||||
return s.r.usage, fmt.Errorf("stream passthrough exceeds %d bytes", totalLimit)
|
||||
}
|
||||
total += len(line)
|
||||
if err := writeFlush(w, []byte(line)); err != nil {
|
||||
return s.r.usage, err
|
||||
}
|
||||
}
|
||||
size += len(line)
|
||||
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||
if line == "" {
|
||||
if err := dispatch(); err != nil {
|
||||
return s.r.usage, err
|
||||
}
|
||||
size = 0
|
||||
if s.terminal {
|
||||
break
|
||||
}
|
||||
} else if !strings.HasPrefix(line, ":") {
|
||||
key, val, _ := strings.Cut(line, ":")
|
||||
val = strings.TrimPrefix(val, " ")
|
||||
switch key {
|
||||
case "data":
|
||||
data = append(data, val)
|
||||
case "event":
|
||||
event = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
if e != io.EOF {
|
||||
return s.r.usage, e
|
||||
}
|
||||
if err := dispatch(); err != nil {
|
||||
return s.r.usage, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if e := ctx.Err(); e != nil {
|
||||
return s.r.usage, e
|
||||
}
|
||||
if from == to {
|
||||
return s.r.usage, nil
|
||||
}
|
||||
if !s.terminal {
|
||||
return s.r.usage, fmt.Errorf("truncated SSE stream: missing terminal event")
|
||||
}
|
||||
if e := s.end(); e != nil {
|
||||
return s.r.usage, e
|
||||
}
|
||||
return s.r.usage, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func limitEvent(s string) string { return "data: " + s + "\n\n" }
|
||||
func TestSSEReadLimits(t *testing.T) {
|
||||
for _, to := range []Kind{Chat, Anthropic} {
|
||||
var out bytes.Buffer
|
||||
_, err := streamWithLimits(context.Background(), &out, strings.NewReader(strings.Repeat("x", 10000)), Chat, to, "", 64, 8, 64)
|
||||
if err == nil || !strings.Contains(err.Error(), "size limit") || out.Len() != 0 {
|
||||
t.Fatalf("%s: %v", to, err)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"12345678", "1234567\n"} {
|
||||
line, err := readSSELine(context.Background(), bufio.NewReader(strings.NewReader(input)), 8)
|
||||
if line != input || (err != nil && err != io.EOF) {
|
||||
t.Fatalf("%q %v", line, err)
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
_, err := streamWithLimits(context.Background(), &out, strings.NewReader(":123456\n:123456\n"), Chat, Chat, "", 12, 8, 16)
|
||||
if err == nil || out.String() != ":123456\n" {
|
||||
t.Fatalf("%q %v", out.String(), err)
|
||||
}
|
||||
}
|
||||
func TestStreamPassthroughTotalLimit(t *testing.T) {
|
||||
frame := "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":null}]}\n\n"
|
||||
var out bytes.Buffer
|
||||
_, err := streamWithLimits(context.Background(), &out, strings.NewReader(strings.Repeat(frame, 4)), Chat, Chat, "", 512, 8, 2*len(frame))
|
||||
if err == nil || !strings.Contains(err.Error(), "passthrough") || out.Len() != 2*len(frame) {
|
||||
t.Fatalf("out=%d %q %v", out.Len(), out.String(), err)
|
||||
}
|
||||
}
|
||||
func TestStreamCumulativeLimits(t *testing.T) {
|
||||
for _, field := range []string{"text", "arguments", "identity"} {
|
||||
delta := object{"content": strings.Repeat("x", 100)}
|
||||
if field != "text" {
|
||||
call := object{"index": 0}
|
||||
if field == "arguments" {
|
||||
call["function"] = object{"arguments": strings.Repeat("x", 100)}
|
||||
} else {
|
||||
call["id"] = strings.Repeat("x", 100)
|
||||
}
|
||||
delta = object{"tool_calls": []any{call}}
|
||||
}
|
||||
event := limitEvent(fmt.Sprintf("{\"choices\":[{\"index\":0,\"delta\":%s}]}", mustLimitJSON(t, delta)))
|
||||
_, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(strings.Repeat(event, 6)), Chat, Responses, "", 512, 8, 512)
|
||||
if err == nil || !strings.Contains(err.Error(), "retained content") {
|
||||
t.Fatalf("%s: %v", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestStreamBlockLimits(t *testing.T) {
|
||||
for _, kind := range []string{"text", "thinking", "reasoning", "summary", "tool"} {
|
||||
from := Anthropic
|
||||
in := limitEvent("{\"type\":\"message_start\",\"message\":{\"content\":[]}}")
|
||||
if kind == "reasoning" || kind == "summary" || kind == "tool" {
|
||||
from = Responses
|
||||
in = ""
|
||||
}
|
||||
if kind == "summary" {
|
||||
in = limitEvent("{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"r\"}}")
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
m := object{"type": "content_block_start", "index": i, "content_block": object{"type": kind, "text": ""}}
|
||||
switch kind {
|
||||
case "thinking":
|
||||
m["content_block"] = object{"type": "thinking", "thinking": "", "signature": ""}
|
||||
case "reasoning", "tool":
|
||||
item := object{"type": "reasoning", "id": "r"}
|
||||
if kind == "tool" {
|
||||
item = object{"type": "function_call", "call_id": "c", "name": "f", "arguments": ""}
|
||||
}
|
||||
m = object{"type": "response.output_item.added", "output_index": i, "item": item}
|
||||
case "summary":
|
||||
m = object{"type": "response.reasoning_summary_part.added", "output_index": 0, "item_id": "r", "summary_index": i, "part": object{"type": "summary_text", "text": ""}}
|
||||
}
|
||||
in += limitEvent(mustLimitJSON(t, m))
|
||||
}
|
||||
_, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(in), from, Chat, "", 1024, 3, 1024)
|
||||
if err == nil || !strings.Contains(err.Error(), "3 blocks") {
|
||||
t.Fatalf("%s: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningIdentityCumulativeLimit(t *testing.T) {
|
||||
var in strings.Builder
|
||||
for i := 0; i < 6; i++ {
|
||||
in.WriteString(limitEvent(mustLimitJSON(t, object{"type": "response.output_item.added", "output_index": i, "item": object{"type": "reasoning", "id": strings.Repeat("r", 100)}})))
|
||||
}
|
||||
_, err := streamWithLimits(context.Background(), io.Discard, strings.NewReader(in.String()), Responses, Chat, "", 512, 8, 512)
|
||||
if err == nil || !strings.Contains(err.Error(), "retained content") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestStreamBufferSemantics(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
s := &streamState{ctx: context.Background(), w: &out, from: Chat, to: Responses, source: map[int]*streamBlock{}, byteLimit: 64, blockLimit: 4}
|
||||
b, err := s.add(0, part{typ: "call"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.delta(b, "{\"a\":"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatal("incomplete identity emitted")
|
||||
}
|
||||
b.id, b.name = "c", "f"
|
||||
if err = s.delta(b, "1}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.args != "{\"a\":1}" || b.sent != 7 {
|
||||
t.Fatal(b.part)
|
||||
}
|
||||
if err = s.close(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := s.add(1, part{typ: "text"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range []string{"ab", "cd"} {
|
||||
if err = s.delta(text, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if text.text != "abcd" {
|
||||
t.Fatal(text.text)
|
||||
}
|
||||
if err = s.finish("stop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = s.end(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "abcd") || !strings.Contains(out.String(), "response.completed") {
|
||||
t.Fatal(out.String())
|
||||
}
|
||||
s.finished = false
|
||||
text.closed = false
|
||||
s.byteLimit = s.retained
|
||||
if err = s.delta(text, "x"); err == nil || text.text != "abcd" {
|
||||
t.Fatal("overflow changed content")
|
||||
}
|
||||
}
|
||||
func mustLimitJSON(t *testing.T, m object) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMergeUsageSizeBoundary(t *testing.T) {
|
||||
for _, kind := range []Kind{Chat, Responses, Anthropic} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
m := object{"padding": strings.Repeat("x", maxStreamUsageBytes-len(`{"padding":""}`))}
|
||||
u, err := mergeUsage(kind, Usage{}, object{"usage": m})
|
||||
if err != nil || len(u.Raw) != maxStreamUsageBytes {
|
||||
t.Fatalf("exact limit: size=%d err=%v", len(u.Raw), err)
|
||||
}
|
||||
for _, update := range []object{
|
||||
{"usage": object{"padding": str(m["padding"]) + "x"}},
|
||||
{"usage": object{"new_key": 1}},
|
||||
} {
|
||||
got, err := mergeUsage(kind, u, update)
|
||||
if err == nil || !strings.Contains(err.Error(), "usage exceeds 16384 bytes") || !reflect.DeepEqual(got, u) {
|
||||
t.Fatalf("overflow: retained=%d err=%v", len(got.Raw), err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
got, err := mergeUsage(kind, u, object{"usage": m})
|
||||
if err != nil || !reflect.DeepEqual(got, u) {
|
||||
t.Fatalf("replacement at limit: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamUsageSizeLimits(t *testing.T) {
|
||||
for _, from := range []Kind{Chat, Responses, Anthropic} {
|
||||
for _, to := range []Kind{Chat, Responses, Anthropic} {
|
||||
for _, cumulative := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/%s/cumulative=%t", from, to, cumulative), func(t *testing.T) {
|
||||
var input strings.Builder
|
||||
writeUsage := func(u object) {
|
||||
m := object{"usage": u}
|
||||
switch from {
|
||||
case Anthropic:
|
||||
m["type"] = "ping"
|
||||
case Responses:
|
||||
m = object{"type": "response.in_progress", "response": m}
|
||||
}
|
||||
input.WriteString(limitEvent(mustLimitJSON(t, m)))
|
||||
}
|
||||
writeUsage(object{"prompt_tokens": 7, "input_tokens": 7})
|
||||
if cumulative {
|
||||
for i := 0; i < 32; i++ {
|
||||
writeUsage(object{fmt.Sprintf("key_%d", i): strings.Repeat("x", 1024)})
|
||||
}
|
||||
} else {
|
||||
writeUsage(object{"padding": strings.Repeat("x", maxStreamUsageBytes)})
|
||||
}
|
||||
u, err := Stream(context.Background(), io.Discard, strings.NewReader(input.String()), from, to, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "usage exceeds 16384 bytes") {
|
||||
t.Fatalf("expected usage limit error, got %v", err)
|
||||
}
|
||||
if !u.Known || u.Input != 7 || len(u.Raw) > maxStreamUsageBytes {
|
||||
t.Fatalf("lost valid usage: known=%t input=%d size=%d", u.Known, u.Input, len(u.Raw))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUsagePartialUpdates(t *testing.T) {
|
||||
u, err := mergeUsage(Anthropic, Usage{}, object{"message": object{"usage": object{"input_tokens": 5, "cache_read_input_tokens": 2}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err = mergeUsage(Anthropic, u, object{"usage": object{"output_tokens": 3}})
|
||||
if err != nil || !u.Known || u.Input != 7 || u.Output != 3 || u.CacheRead != 2 {
|
||||
t.Fatalf("partial update: %+v err=%v", u, err)
|
||||
}
|
||||
got, err := mergeUsage(Anthropic, u, object{})
|
||||
if err != nil || !reflect.DeepEqual(got, u) {
|
||||
t.Fatalf("missing usage changed totals: %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMalformedInitialToolArguments(t *testing.T) {
|
||||
for _, bad := range []string{`null`, `[]`, `12`, `"not-an-object"`} {
|
||||
src := `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tool1","name":"fn","input":` + bad + `}}` + "\n\n"
|
||||
_, err := Stream(context.Background(), io.Discard, strings.NewReader(src), Anthropic, Chat, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "tool input must be an object") {
|
||||
t.Fatalf("input %s: %v", bad, err)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{``, `,"arguments":null`, `,"arguments":{}`, `,"arguments":12`} {
|
||||
src := `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call1","name":"fn"` + field + `}}` + "\n\n"
|
||||
_, err := Stream(context.Background(), io.Discard, strings.NewReader(src), Responses, Chat, "m")
|
||||
if err == nil || !strings.Contains(err.Error(), "function arguments must be a string") {
|
||||
t.Fatalf("field %s: %v", field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNodeLimit(t *testing.T) {
|
||||
dense := `{"model":"m","pad":[` + strings.Repeat("0,", maxJSONNodes) + `0]}`
|
||||
if _, err := ConvertRequest(Chat, Chat, []byte(dense), "m"); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("dense body accepted: %v", err)
|
||||
}
|
||||
under := `{"model":"m","pad":[` + strings.Repeat("0,", maxJSONNodes/2) + `0]}`
|
||||
if _, err := ConvertRequest(Chat, Chat, []byte(under), "m"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ConvertRequest(Chat, Chat, []byte(`{"broken":`), "m"); err == nil || strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("malformed JSON misreported: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user