CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI
- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens, management API /api/* (providers CRUD, apply/restore, settings, backups, logs) - Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming state machine keyed by content block index (text, tool_use, parallel calls) - ~/.claude/settings.json guarded read/write: timestamped backups, managed env keys only, one-click restore - Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout, stdin pipe watchdog prevents orphans, kills the child on exit - React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query - Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
1 parent
8ee5e98ea3
commit
f8acf31ebc
71 files changed
+15727
-1
No files matched your search
@@ -0,0 +1,276 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// ConvertRequest converts an Anthropic Messages request into a Chat
|
||||
// Completions request addressed to the given upstream model.
|
||||
func ConvertRequest(req *MessagesRequest, model string) (*chatcmpl.Request, error) {
|
||||
out := &chatcmpl.Request{
|
||||
Model: model,
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stop: req.StopSequences,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
if txt := SystemText(req.System); txt != "" {
|
||||
out.Messages = append(out.Messages, chatcmpl.Message{Role: "system", Content: txt})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
blocks, err := ParseContent(m.Content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse message content: %w", err)
|
||||
}
|
||||
converted, err := convertMessage(m.Role, blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Messages = append(out.Messages, converted...)
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
out.Tools = append(out.Tools, chatcmpl.Tool{
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolFunction{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.InputSchema,
|
||||
},
|
||||
})
|
||||
}
|
||||
choice, err := convertToolChoice(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.ToolChoice = choice
|
||||
if req.Stream {
|
||||
out.StreamOptions = &chatcmpl.StreamOptions{IncludeUsage: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func convertMessage(role string, blocks []Block) ([]chatcmpl.Message, error) {
|
||||
var out []chatcmpl.Message
|
||||
var parts []chatcmpl.ContentPart
|
||||
flush := func() {
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 && parts[0].Type == "text" {
|
||||
out = append(out, chatcmpl.Message{Role: role, Content: parts[0].Text})
|
||||
} else {
|
||||
out = append(out, chatcmpl.Message{Role: role, Content: parts})
|
||||
}
|
||||
parts = nil
|
||||
}
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
parts = append(parts, chatcmpl.ContentPart{Type: "text", Text: b.Text})
|
||||
case "image":
|
||||
url, err := imageURL(b.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, chatcmpl.ContentPart{Type: "image_url", ImageURL: &chatcmpl.ImageURL{URL: url}})
|
||||
case "tool_use":
|
||||
flush()
|
||||
args := string(b.Input)
|
||||
if strings.TrimSpace(args) == "" {
|
||||
args = "{}"
|
||||
}
|
||||
out = append(out, chatcmpl.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []chatcmpl.ToolCall{{
|
||||
ID: b.ID,
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: b.Name, Arguments: args},
|
||||
}},
|
||||
})
|
||||
case "tool_result":
|
||||
flush()
|
||||
out = append(out, chatcmpl.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: b.ToolUseID,
|
||||
Content: TextOf(b.Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func imageURL(src *ImageSource) (string, error) {
|
||||
if src == nil {
|
||||
return "", fmt.Errorf("image block without source")
|
||||
}
|
||||
switch {
|
||||
case src.URL != "":
|
||||
return src.URL, nil
|
||||
case src.Type == "base64" && src.Data != "":
|
||||
mediaType := src.MediaType
|
||||
if mediaType == "" {
|
||||
mediaType = "image/png"
|
||||
}
|
||||
return "data:" + mediaType + ";base64," + src.Data, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported image source type %q", src.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func convertToolChoice(raw json.RawMessage) (any, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return toolChoiceFromType(s)
|
||||
}
|
||||
var obj struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(raw, &obj) == nil {
|
||||
if obj.Type == "tool" && obj.Name != "" {
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{"name": obj.Name},
|
||||
}, nil
|
||||
}
|
||||
return toolChoiceFromType(obj.Type)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func toolChoiceFromType(t string) (any, error) {
|
||||
switch t {
|
||||
case "auto", "none":
|
||||
return t, nil
|
||||
case "any":
|
||||
return "required", nil
|
||||
case "":
|
||||
return nil, nil
|
||||
default:
|
||||
// Unknown tool choice: let the upstream decide.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertResponse converts a Chat Completions response into an Anthropic
|
||||
// Messages response. requestedModel is echoed back to the client.
|
||||
func ConvertResponse(resp *chatcmpl.Response, requestedModel string) *MessagesResponse {
|
||||
out := &MessagesResponse{
|
||||
ID: "msg_" + resp.ID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: requestedModel,
|
||||
Content: []Block{},
|
||||
}
|
||||
if len(resp.Choices) > 0 {
|
||||
c := resp.Choices[0]
|
||||
out.Content = convertAssistantMessage(c.Message)
|
||||
out.StopReason = MapStopReason(c.FinishReason)
|
||||
} else {
|
||||
out.StopReason = "end_turn"
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
out.Usage = Usage{
|
||||
InputTokens: resp.Usage.PromptTokens,
|
||||
OutputTokens: resp.Usage.CompletionTokens,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertAssistantMessage(m chatcmpl.Message) []Block {
|
||||
var blocks []Block
|
||||
if txt := messageText(m.Content); txt != "" {
|
||||
blocks = append(blocks, Block{Type: "text", Text: txt})
|
||||
}
|
||||
for _, tc := range m.ToolCalls {
|
||||
input := json.RawMessage(tc.Function.Arguments)
|
||||
if len(input) == 0 {
|
||||
input = json.RawMessage("{}")
|
||||
}
|
||||
blocks = append(blocks, Block{
|
||||
Type: "tool_use",
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func messageText(content any) string {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []chatcmpl.ContentPart:
|
||||
var sb strings.Builder
|
||||
for _, p := range v {
|
||||
if p.Type == "text" {
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MapStopReason maps a Chat Completions finish_reason onto an Anthropic
|
||||
// stop_reason.
|
||||
func MapStopReason(finish string) string {
|
||||
switch finish {
|
||||
case "stop", "":
|
||||
return "end_turn"
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
case "tool_calls", "function_call":
|
||||
return "tool_use"
|
||||
case "content_filter":
|
||||
return "refusal"
|
||||
default:
|
||||
return "end_turn"
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateTokens returns a rough input-token estimate for a request. It
|
||||
// powers /v1/messages/count_tokens so Claude Code's context meter works
|
||||
// without a real tokenizer.
|
||||
func EstimateTokens(req *MessagesRequest) int {
|
||||
total := len(req.Model)/4 + 4
|
||||
total += len(SystemText(req.System)) / 4
|
||||
for _, m := range req.Messages {
|
||||
total += len(m.Role) + 4
|
||||
blocks, err := ParseContent(m.Content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, b := range blocks {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
total += len(b.Text) / 4
|
||||
case "tool_use":
|
||||
total += len(b.Name)/4 + len(b.Input)/4
|
||||
case "tool_result":
|
||||
total += len(TextOf(b.Content)) / 4
|
||||
case "image":
|
||||
total += 1100
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range req.Tools {
|
||||
total += len(t.Name)/4 + len(t.Description)/4 + len(t.InputSchema)/4
|
||||
}
|
||||
if total < 1 {
|
||||
total = 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestConvertRequestTextAndSystem(t *testing.T) {
|
||||
req := &MessagesRequest{
|
||||
Model: "claude-sonnet-4-5",
|
||||
MaxTokens: 1024,
|
||||
System: json.RawMessage(`"You are helpful"`),
|
||||
Messages: []Message{
|
||||
{Role: "user", Content: json.RawMessage(`"hi"`)},
|
||||
},
|
||||
Temperature: ptr(0.5),
|
||||
TopP: ptr(0.9),
|
||||
}
|
||||
out, err := ConvertRequest(req, "ds-chat")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
if out.Model != "ds-chat" {
|
||||
t.Errorf("model = %q", out.Model)
|
||||
}
|
||||
if out.MaxTokens != 1024 {
|
||||
t.Errorf("max tokens = %d", out.MaxTokens)
|
||||
}
|
||||
if len(out.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2 (system + user)", len(out.Messages))
|
||||
}
|
||||
if out.Messages[0].Role != "system" || out.Messages[0].Content != "You are helpful" {
|
||||
t.Errorf("system message = %+v", out.Messages[0])
|
||||
}
|
||||
if out.Messages[1].Role != "user" || out.Messages[1].Content != "hi" {
|
||||
t.Errorf("user message = %+v", out.Messages[1])
|
||||
}
|
||||
if out.StreamOptions != nil {
|
||||
t.Error("stream_options set for non-stream request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRequestSystemBlocks(t *testing.T) {
|
||||
req := &MessagesRequest{
|
||||
Model: "m",
|
||||
System: json.RawMessage(`[{"type":"text","text":"one"},{"type":"text","text":"two","cache_control":{"type":"ephemeral"}}]`),
|
||||
}
|
||||
out, err := ConvertRequest(req, "m")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
if len(out.Messages) != 1 || out.Messages[0].Content != "one\n\ntwo" {
|
||||
t.Fatalf("system flattening = %+v", out.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRequestToolsAndToolResult(t *testing.T) {
|
||||
req := &MessagesRequest{
|
||||
Model: "claude-sonnet-4-5",
|
||||
Messages: []Message{
|
||||
{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"look"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAA="}}]`)},
|
||||
{Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"read_file","input":{"path":"a.txt"}}]`)},
|
||||
{Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"file body"}]`)},
|
||||
},
|
||||
Tools: []Tool{{Name: "read_file", Description: "reads", InputSchema: json.RawMessage(`{"type":"object"}`)}},
|
||||
}
|
||||
out, err := ConvertRequest(req, "ds-chat")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
wantRoles := []string{"user", "assistant", "assistant", "tool"}
|
||||
if len(out.Messages) != len(wantRoles) {
|
||||
t.Fatalf("messages = %d (%+v), want %d", len(out.Messages), out.Messages, len(wantRoles))
|
||||
}
|
||||
for i, want := range wantRoles {
|
||||
if out.Messages[i].Role != want {
|
||||
t.Errorf("message %d role = %q, want %q", i, out.Messages[i].Role, want)
|
||||
}
|
||||
}
|
||||
// user message became multi-part with an image data URL.
|
||||
parts, ok := out.Messages[0].Content.([]chatcmpl.ContentPart)
|
||||
if !ok || len(parts) != 2 || parts[1].Type != "image_url" {
|
||||
t.Fatalf("user content = %+v", out.Messages[0].Content)
|
||||
}
|
||||
if parts[1].ImageURL.URL != "data:image/png;base64,AAA=" {
|
||||
t.Errorf("image url = %q", parts[1].ImageURL.URL)
|
||||
}
|
||||
// assistant tool_use became a tool_calls message.
|
||||
if len(out.Messages[2].ToolCalls) != 1 {
|
||||
t.Fatalf("tool calls = %+v", out.Messages[2].ToolCalls)
|
||||
}
|
||||
tc := out.Messages[2].ToolCalls[0]
|
||||
if tc.ID != "toolu_1" || tc.Function.Name != "read_file" || tc.Function.Arguments != `{"path":"a.txt"}` {
|
||||
t.Errorf("tool call = %+v", tc)
|
||||
}
|
||||
// tool_result became a tool message.
|
||||
if out.Messages[3].ToolCallID != "toolu_1" || out.Messages[3].Content != "file body" {
|
||||
t.Errorf("tool message = %+v", out.Messages[3])
|
||||
}
|
||||
// tools converted to function tools.
|
||||
if len(out.Tools) != 1 || out.Tools[0].Type != "function" || out.Tools[0].Function.Name != "read_file" {
|
||||
t.Errorf("tools = %+v", out.Tools)
|
||||
}
|
||||
if string(out.Tools[0].Function.Parameters) != `{"type":"object"}` {
|
||||
t.Errorf("tool parameters = %s", out.Tools[0].Function.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRequestStreamSetsUsageOption(t *testing.T) {
|
||||
out, err := ConvertRequest(&MessagesRequest{Model: "m", Stream: true}, "m")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest: %v", err)
|
||||
}
|
||||
if out.StreamOptions == nil || !out.StreamOptions.IncludeUsage {
|
||||
t.Errorf("stream options = %+v, want include_usage", out.StreamOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToolChoice(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want any
|
||||
}{
|
||||
{`"auto"`, "auto"},
|
||||
{`"any"`, "required"},
|
||||
{`"none"`, "none"},
|
||||
{`{"type":"tool","name":"read_file"}`, map[string]any{"type": "function", "function": map[string]any{"name": "read_file"}}},
|
||||
{`{"type":"auto"}`, "auto"},
|
||||
{`{"type":"weird"}`, nil},
|
||||
{`"weird"`, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := convertToolChoice(json.RawMessage(tc.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("convertToolChoice(%s): %v", tc.raw, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("convertToolChoice(%s) = %#v, want %#v", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertResponse(t *testing.T) {
|
||||
resp := &chatcmpl.Response{
|
||||
ID: "chatcmpl-1",
|
||||
Model: "ds-chat",
|
||||
Choices: []chatcmpl.Choice{{
|
||||
FinishReason: "tool_calls",
|
||||
Message: chatcmpl.Message{
|
||||
Role: "assistant",
|
||||
Content: "thinking",
|
||||
ToolCalls: []chatcmpl.ToolCall{{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"a.txt"}`,
|
||||
},
|
||||
}},
|
||||
},
|
||||
}},
|
||||
Usage: &chatcmpl.Usage{PromptTokens: 11, CompletionTokens: 7},
|
||||
}
|
||||
out := ConvertResponse(resp, "claude-sonnet-4-5")
|
||||
if out.Type != "message" || out.Role != "assistant" {
|
||||
t.Errorf("envelope = %+v", out)
|
||||
}
|
||||
if out.Model != "claude-sonnet-4-5" {
|
||||
t.Errorf("model = %q, want the requested model echoed", out.Model)
|
||||
}
|
||||
if len(out.Content) != 2 {
|
||||
t.Fatalf("content = %+v", out.Content)
|
||||
}
|
||||
if out.Content[0].Type != "text" || out.Content[0].Text != "thinking" {
|
||||
t.Errorf("text block = %+v", out.Content[0])
|
||||
}
|
||||
if out.Content[1].Type != "tool_use" || out.Content[1].ID != "call_1" || out.Content[1].Name != "read_file" {
|
||||
t.Errorf("tool block = %+v", out.Content[1])
|
||||
}
|
||||
if string(out.Content[1].Input) != `{"path":"a.txt"}` {
|
||||
t.Errorf("tool input = %s", out.Content[1].Input)
|
||||
}
|
||||
if out.StopReason != "tool_use" {
|
||||
t.Errorf("stop reason = %q", out.StopReason)
|
||||
}
|
||||
if out.Usage.InputTokens != 11 || out.Usage.OutputTokens != 7 {
|
||||
t.Errorf("usage = %+v", out.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapStopReason(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"stop": "end_turn",
|
||||
"length": "max_tokens",
|
||||
"tool_calls": "tool_use",
|
||||
"function_call": "tool_use",
|
||||
"content_filter": "refusal",
|
||||
"": "end_turn",
|
||||
"mystery": "end_turn",
|
||||
}
|
||||
for finish, want := range cases {
|
||||
if got := MapStopReason(finish); got != want {
|
||||
t.Errorf("MapStopReason(%q) = %q, want %q", finish, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTokens(t *testing.T) {
|
||||
small := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"hi"`)}}}
|
||||
long := strings.Repeat("token ", 800)
|
||||
big := &MessagesRequest{Model: "m", Messages: []Message{{Role: "user", Content: json.RawMessage(`"` + long + `"`)}}}
|
||||
if EstimateTokens(small) <= 0 {
|
||||
t.Error("estimate should be positive")
|
||||
}
|
||||
if EstimateTokens(big) <= EstimateTokens(small)*10 {
|
||||
t.Errorf("estimate does not scale: small=%d big=%d", EstimateTokens(small), EstimateTokens(big))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContentStringAndBlocks(t *testing.T) {
|
||||
blocks, err := ParseContent(json.RawMessage(`"plain"`))
|
||||
if err != nil || len(blocks) != 1 || blocks[0].Type != "text" || blocks[0].Text != "plain" {
|
||||
t.Fatalf("string content = %+v (%v)", blocks, err)
|
||||
}
|
||||
blocks, err = ParseContent(json.RawMessage(`null`))
|
||||
if err != nil || blocks != nil {
|
||||
t.Fatalf("null content = %+v (%v)", blocks, err)
|
||||
}
|
||||
if TextOf(json.RawMessage(`"x"`)) != "x" {
|
||||
t.Error("TextOf(string) failed")
|
||||
}
|
||||
if TextOf(json.RawMessage(`[{"type":"text","text":"a"},{"type":"text","text":"b"}]`)) != "ab" {
|
||||
t.Error("TextOf(blocks) failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// flusherOf extracts the http.Flusher when the writer supports it.
|
||||
func flusherOf(w http.ResponseWriter) http.Flusher {
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEvent emits one Server-Sent Event in Anthropic's format:
|
||||
// "event: <name>\ndata: <json>\n\n".
|
||||
func writeEvent(w io.Writer, f http.Flusher, name string, data any) error {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if f != nil {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// StreamConverter synthesizes Anthropic SSE events from Chat Completions
|
||||
// stream chunks.
|
||||
//
|
||||
// Content blocks are tracked by their Anthropic index: one text block
|
||||
// (opened on the first text delta) and one tool_use block per upstream
|
||||
// tool-call index. Deltas always carry the block index, so interleaved
|
||||
// tool-call arguments from the upstream stream are attributed to the
|
||||
// right block. All open blocks are closed in Finish, followed by
|
||||
// message_delta and message_stop.
|
||||
type StreamConverter struct {
|
||||
w http.ResponseWriter
|
||||
flusher http.Flusher
|
||||
model string
|
||||
msgID string
|
||||
|
||||
started bool
|
||||
nextIndex int
|
||||
|
||||
textOpen bool
|
||||
textIndex int
|
||||
|
||||
tools map[int]*toolBlock
|
||||
toolOrder []int
|
||||
|
||||
inputEstimate int
|
||||
outputChars int
|
||||
usage *chatcmpl.Usage
|
||||
finishReason string
|
||||
}
|
||||
|
||||
type toolBlock struct {
|
||||
index int
|
||||
id string
|
||||
name string
|
||||
started bool
|
||||
pending string
|
||||
}
|
||||
|
||||
// NewStreamConverter builds a converter writing to w. model is echoed in
|
||||
// message_start; inputTokens seeds the usage shown to the client.
|
||||
func NewStreamConverter(w http.ResponseWriter, model string, inputTokens int) *StreamConverter {
|
||||
return &StreamConverter{
|
||||
w: w,
|
||||
flusher: flusherOf(w),
|
||||
model: model,
|
||||
msgID: "msg_" + randHex(16),
|
||||
tools: map[int]*toolBlock{},
|
||||
inputEstimate: inputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteStart sends the SSE headers and the message_start event.
|
||||
func (s *StreamConverter) WriteStart() error {
|
||||
h := s.w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
s.w.WriteHeader(http.StatusOK)
|
||||
s.started = true
|
||||
return writeEvent(s.w, s.flusher, "message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": s.msgID,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []any{},
|
||||
"model": s.model,
|
||||
"stop_reason": nil,
|
||||
"stop_sequence": nil,
|
||||
"usage": map[string]any{"input_tokens": s.inputEstimate, "output_tokens": 0},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// OnChunk folds one upstream chunk into the Anthropic event stream.
|
||||
func (s *StreamConverter) OnChunk(chunk *chatcmpl.StreamChunk) error {
|
||||
if chunk == nil {
|
||||
return nil
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
u := *chunk.Usage
|
||||
s.usage = &u
|
||||
}
|
||||
for _, choice := range chunk.Choices {
|
||||
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
|
||||
if err := s.writeText(*choice.Delta.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
if err := s.toolDelta(tc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
||||
s.finishReason = *choice.FinishReason
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamConverter) writeText(text string) error {
|
||||
if !s.textOpen {
|
||||
s.textIndex = s.nextIndex
|
||||
s.nextIndex++
|
||||
s.textOpen = true
|
||||
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": s.textIndex,
|
||||
"content_block": map[string]any{"type": "text", "text": ""},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.outputChars += len(text)
|
||||
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": s.textIndex,
|
||||
"delta": map[string]any{"type": "text_delta", "text": text},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StreamConverter) toolDelta(tc chatcmpl.ToolCall) error {
|
||||
st := s.tools[tc.Index]
|
||||
if st == nil {
|
||||
st = &toolBlock{}
|
||||
s.tools[tc.Index] = st
|
||||
s.toolOrder = append(s.toolOrder, tc.Index)
|
||||
}
|
||||
if tc.ID != "" && st.id == "" {
|
||||
st.id = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" && st.name == "" {
|
||||
st.name = tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
st.pending += tc.Function.Arguments
|
||||
}
|
||||
// Open the block once we know a name or have arguments to flush, so
|
||||
// the client never sees an unnamed tool_use block.
|
||||
if !st.started && (st.name != "" || st.pending != "") {
|
||||
st.index = s.nextIndex
|
||||
s.nextIndex++
|
||||
st.started = true
|
||||
if st.id == "" {
|
||||
st.id = fmt.Sprintf("toolu_%s_%d", s.msgID[len("msg_"):], tc.Index)
|
||||
}
|
||||
if err := writeEvent(s.w, s.flusher, "content_block_start", map[string]any{
|
||||
"type": "content_block_start",
|
||||
"index": st.index,
|
||||
"content_block": map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": st.id,
|
||||
"name": st.name,
|
||||
"input": map[string]any{},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.outputChars += len(st.name)
|
||||
}
|
||||
if st.started && st.pending != "" {
|
||||
s.outputChars += len(st.pending)
|
||||
pending := st.pending
|
||||
st.pending = ""
|
||||
return writeEvent(s.w, s.flusher, "content_block_delta", map[string]any{
|
||||
"type": "content_block_delta",
|
||||
"index": st.index,
|
||||
"delta": map[string]any{"type": "input_json_delta", "partial_json": pending},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish closes every open block and ends the message.
|
||||
func (s *StreamConverter) Finish() error {
|
||||
if s.textOpen {
|
||||
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
|
||||
"type": "content_block_stop",
|
||||
"index": s.textIndex,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
s.textOpen = false
|
||||
}
|
||||
for _, idx := range s.toolOrder {
|
||||
st := s.tools[idx]
|
||||
if st == nil || !st.started {
|
||||
continue
|
||||
}
|
||||
if err := writeEvent(s.w, s.flusher, "content_block_stop", map[string]any{
|
||||
"type": "content_block_stop",
|
||||
"index": st.index,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
outputTokens := 0
|
||||
if s.usage != nil {
|
||||
outputTokens = s.usage.CompletionTokens
|
||||
} else {
|
||||
outputTokens = (s.outputChars + 3) / 4
|
||||
}
|
||||
usage := map[string]any{"output_tokens": outputTokens}
|
||||
if s.usage != nil {
|
||||
usage["input_tokens"] = s.usage.PromptTokens
|
||||
}
|
||||
if err := writeEvent(s.w, s.flusher, "message_delta", map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": MapStopReason(s.finishReason), "stop_sequence": nil},
|
||||
"usage": usage,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeEvent(s.w, s.flusher, "message_stop", map[string]any{"type": "message_stop"})
|
||||
}
|
||||
|
||||
// WriteError terminates the stream with an Anthropic error event.
|
||||
func (s *StreamConverter) WriteError(errType, message string) {
|
||||
_ = writeEvent(s.w, s.flusher, "error", map[string]any{
|
||||
"type": "error",
|
||||
"error": map[string]any{"type": errType, "message": message},
|
||||
})
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%x", n)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.rutua.cn/kate522/CrossRelay/internal/chatcmpl"
|
||||
)
|
||||
|
||||
// captureWriter records everything the converter writes.
|
||||
type captureWriter struct {
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
status int
|
||||
flushes int
|
||||
}
|
||||
|
||||
func newCaptureWriter() *captureWriter {
|
||||
return &captureWriter{header: http.Header{}}
|
||||
}
|
||||
|
||||
func (c *captureWriter) Header() http.Header { return c.header }
|
||||
func (c *captureWriter) Write(b []byte) (int, error) { return c.body.Write(b) }
|
||||
func (c *captureWriter) WriteHeader(status int) { c.status = status }
|
||||
func (c *captureWriter) Flush() { c.flushes++ }
|
||||
|
||||
type sseEvent struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func parseSSE(t *testing.T, data string) []sseEvent {
|
||||
t.Helper()
|
||||
var events []sseEvent
|
||||
for _, block := range strings.Split(strings.TrimSpace(data), "\n\n") {
|
||||
if block == "" {
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
var payload string
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event: "):
|
||||
name = strings.TrimPrefix(line, "event: ")
|
||||
case strings.HasPrefix(line, "data: "):
|
||||
payload = strings.TrimPrefix(line, "data: ")
|
||||
}
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
|
||||
t.Fatalf("event %s payload is not JSON: %v (%s)", name, err, payload)
|
||||
}
|
||||
events = append(events, sseEvent{name: name, data: parsed})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func eventNames(events []sseEvent) []string {
|
||||
names := make([]string, len(events))
|
||||
for i, e := range events {
|
||||
names[i] = e.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func textChunk(text string) *chatcmpl.StreamChunk {
|
||||
return &chatcmpl.StreamChunk{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{Content: &text}}}}
|
||||
}
|
||||
|
||||
func finishChunk(reason string, usage *chatcmpl.Usage) *chatcmpl.StreamChunk {
|
||||
return &chatcmpl.StreamChunk{
|
||||
Choices: []chatcmpl.ChunkChoice{{FinishReason: &reason}},
|
||||
Usage: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func runConverter(t *testing.T, chunks []*chatcmpl.StreamChunk) []sseEvent {
|
||||
t.Helper()
|
||||
w := newCaptureWriter()
|
||||
conv := NewStreamConverter(w, "claude-sonnet-4-5", 42)
|
||||
if err := conv.WriteStart(); err != nil {
|
||||
t.Fatalf("WriteStart: %v", err)
|
||||
}
|
||||
for _, c := range chunks {
|
||||
if err := conv.OnChunk(c); err != nil {
|
||||
t.Fatalf("OnChunk: %v", err)
|
||||
}
|
||||
}
|
||||
if err := conv.Finish(); err != nil {
|
||||
t.Fatalf("Finish: %v", err)
|
||||
}
|
||||
if w.status != http.StatusOK {
|
||||
t.Errorf("status = %d", w.status)
|
||||
}
|
||||
if ct := w.header.Get("Content-Type"); ct != "text/event-stream; charset=utf-8" {
|
||||
t.Errorf("content type = %q", ct)
|
||||
}
|
||||
return parseSSE(t, w.body.String())
|
||||
}
|
||||
|
||||
func TestStreamTextOnly(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
textChunk("Hello"),
|
||||
textChunk(", world"),
|
||||
finishChunk("stop", &chatcmpl.Usage{PromptTokens: 10, CompletionTokens: 3}),
|
||||
})
|
||||
wantNames := []string{
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
}
|
||||
if got := eventNames(events); !equalStrings(got, wantNames) {
|
||||
t.Fatalf("events = %v, want %v", got, wantNames)
|
||||
}
|
||||
start := events[0].data["message"].(map[string]any)
|
||||
if start["model"] != "claude-sonnet-4-5" {
|
||||
t.Errorf("message model = %v", start["model"])
|
||||
}
|
||||
usage := start["usage"].(map[string]any)
|
||||
if usage["input_tokens"].(float64) != 42 {
|
||||
t.Errorf("input tokens = %v, want 42", usage["input_tokens"])
|
||||
}
|
||||
if events[2].data["delta"].(map[string]any)["text"] != "Hello" {
|
||||
t.Errorf("first delta = %v", events[2].data["delta"])
|
||||
}
|
||||
delta := events[5].data["delta"].(map[string]any)
|
||||
if delta["stop_reason"] != "end_turn" {
|
||||
t.Errorf("stop reason = %v", delta["stop_reason"])
|
||||
}
|
||||
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) != 3 {
|
||||
t.Errorf("output tokens = %v", events[5].data["usage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCall(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0,
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "read_file"},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0,
|
||||
Function: chatcmpl.ToolCallFunction{Arguments: `{"path":`},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0,
|
||||
Function: chatcmpl.ToolCallFunction{Arguments: `"a.txt"}`},
|
||||
}}}}}},
|
||||
finishChunk("tool_calls", nil),
|
||||
})
|
||||
wantNames := []string{
|
||||
"message_start",
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
}
|
||||
if got := eventNames(events); !equalStrings(got, wantNames) {
|
||||
t.Fatalf("events = %v, want %v", got, wantNames)
|
||||
}
|
||||
block := events[1].data["content_block"].(map[string]any)
|
||||
if block["type"] != "tool_use" || block["id"] != "call_1" || block["name"] != "read_file" {
|
||||
t.Fatalf("tool block = %v", block)
|
||||
}
|
||||
if events[2].data["delta"].(map[string]any)["partial_json"] != `{"path":` {
|
||||
t.Errorf("first args delta = %v", events[2].data["delta"])
|
||||
}
|
||||
if events[3].data["delta"].(map[string]any)["partial_json"] != `"a.txt"}` {
|
||||
t.Errorf("second args delta = %v", events[3].data["delta"])
|
||||
}
|
||||
// Every event for the same block carries index 0.
|
||||
for _, e := range events[1:5] {
|
||||
if e.data["index"].(float64) != 0 {
|
||||
t.Errorf("%s index = %v, want 0", e.name, e.data["index"])
|
||||
}
|
||||
}
|
||||
if events[5].data["delta"].(map[string]any)["stop_reason"] != "tool_use" {
|
||||
t.Errorf("stop reason = %v", events[5].data["delta"])
|
||||
}
|
||||
// No usage chunk from upstream: output tokens are estimated, not zero.
|
||||
if events[5].data["usage"].(map[string]any)["output_tokens"].(float64) <= 0 {
|
||||
t.Error("estimated output tokens should be positive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamParallelToolCalls(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 0, ID: "call_a", Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "tool_a"},
|
||||
}}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{{
|
||||
Index: 1, ID: "call_b", Type: "function",
|
||||
Function: chatcmpl.ToolCallFunction{Name: "tool_b", Arguments: `{}`},
|
||||
}}}}}},
|
||||
finishChunk("tool_calls", nil),
|
||||
})
|
||||
// Two separate blocks: index 0 for tool_a, index 1 for tool_b.
|
||||
starts := 0
|
||||
names := map[float64]string{}
|
||||
for _, e := range events {
|
||||
if e.name != "content_block_start" {
|
||||
continue
|
||||
}
|
||||
starts++
|
||||
idx := e.data["index"].(float64)
|
||||
names[idx] = e.data["content_block"].(map[string]any)["name"].(string)
|
||||
}
|
||||
if starts != 2 {
|
||||
t.Fatalf("content_block_start count = %d, want 2", starts)
|
||||
}
|
||||
if names[0] != "tool_a" || names[1] != "tool_b" {
|
||||
t.Errorf("block names = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamInterleavedToolDeltas(t *testing.T) {
|
||||
// Upstream sends argument deltas for two indices in an interleaved
|
||||
// order; each delta must land on its own block index.
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
|
||||
{Index: 0, ID: "a", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_a"}},
|
||||
}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
|
||||
{Index: 1, ID: "b", Type: "function", Function: chatcmpl.ToolCallFunction{Name: "tool_b"}},
|
||||
}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
|
||||
{Index: 0, Function: chatcmpl.ToolCallFunction{Arguments: `{"x":1}`}},
|
||||
}}}}},
|
||||
{Choices: []chatcmpl.ChunkChoice{{Delta: chatcmpl.Delta{ToolCalls: []chatcmpl.ToolCall{
|
||||
{Index: 1, Function: chatcmpl.ToolCallFunction{Arguments: `{"y":2}`}},
|
||||
}}}}},
|
||||
finishChunk("tool_calls", nil),
|
||||
})
|
||||
args := map[float64]string{}
|
||||
for _, e := range events {
|
||||
if e.name != "content_block_delta" {
|
||||
continue
|
||||
}
|
||||
idx := e.data["index"].(float64)
|
||||
args[idx] += e.data["delta"].(map[string]any)["partial_json"].(string)
|
||||
}
|
||||
if args[0] != `{"x":1}` || args[1] != `{"y":2}` {
|
||||
t.Errorf("interleaved args = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamEmptyUpstream(t *testing.T) {
|
||||
events := runConverter(t, []*chatcmpl.StreamChunk{})
|
||||
want := []string{"message_start", "message_delta", "message_stop"}
|
||||
if got := eventNames(events); !equalStrings(got, want) {
|
||||
t.Fatalf("events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package anthropic describes the Anthropic Messages API that Claude
|
||||
// Code speaks and converts it to and from Chat Completions.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// MessagesRequest is the Anthropic Messages API request body.
|
||||
type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Messages []Message `json:"messages"`
|
||||
System json.RawMessage `json:"system,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// Message is one conversation turn. Content is either a plain string or
|
||||
// an array of blocks.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// Tool is an Anthropic tool definition.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
||||
}
|
||||
|
||||
// Block is a single content block inside a message. Only the fields
|
||||
// belonging to Type are populated.
|
||||
type Block struct {
|
||||
Type string `json:"type"`
|
||||
// text
|
||||
Text string `json:"text,omitempty"`
|
||||
// image
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
// tool_use
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
// tool_result
|
||||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
// cache_control is accepted but never forwarded upstream.
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
}
|
||||
|
||||
// ImageSource is an image referenced by a user message.
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// MessagesResponse is the Anthropic Messages API response body.
|
||||
type MessagesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []Block `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence *string `json:"stop_sequence"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Usage reports token counts.
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// ErrorResponse is the Anthropic error envelope.
|
||||
type ErrorResponse struct {
|
||||
Type string `json:"type"`
|
||||
Error Detail `json:"error"`
|
||||
}
|
||||
|
||||
// Detail is the error payload.
|
||||
type Detail struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CountTokensResponse is the response of /v1/messages/count_tokens.
|
||||
type CountTokensResponse struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
|
||||
// ParseContent decodes a message content field into blocks. A plain
|
||||
// string becomes a single text block.
|
||||
func ParseContent(raw json.RawMessage) ([]Block, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || string(trimmed) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []Block{{Type: "text", Text: s}}, nil
|
||||
}
|
||||
var blocks []Block
|
||||
if err := json.Unmarshal(trimmed, &blocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// SystemText flattens a system field (string or block array) into plain
|
||||
// text.
|
||||
func SystemText(raw json.RawMessage) string {
|
||||
blocks, err := ParseContent(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" && b.Text != "" {
|
||||
parts = append(parts, b.Text)
|
||||
}
|
||||
}
|
||||
return joinBlocks(parts)
|
||||
}
|
||||
|
||||
// TextOf extracts plain text from a tool_result content field.
|
||||
func TextOf(raw json.RawMessage) string {
|
||||
blocks, err := ParseContent(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var sb bytes.Buffer
|
||||
for _, b := range blocks {
|
||||
if b.Type == "text" {
|
||||
sb.WriteString(b.Text)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func joinBlocks(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
out += "\n\n" + p
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user