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:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+276
View File
@@ -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
}
+241
View File
@@ -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")
}
}
+32
View File
@@ -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
}
+244
View File
@@ -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)
}
+277
View File
@@ -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
}
+166
View File
@@ -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
}