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
|
||||
}
|
||||
Reference in new issue
Block a user