feat(server): API relay gateway backend M0-M4

Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with
quotas, proxy gateway with weighted channel failover and health checks,
usage/billing ledger, cross-protocol conversion (Anthropic Messages /
OpenAI Chat Completions / OpenAI Responses), and channel/model admin API.
Channels declare native API formats and auto-convert the rest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
package convert
import (
"encoding/json"
"strings"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/responses"
)
// CanonicalRequest is the gateway-internal standard form (OpenAI chat shape).
// Every protocol is converted into this before being emitted to a channel.
type CanonicalRequest struct {
Model string
System string
Messages []openai.ChatMessage
Stream bool
Temperature *float64
TopP *float64
MaxTokens *int
Stop []string
Tools []openai.Tool
ToolChoice json.RawMessage
ResponseFormat json.RawMessage
// Extra passthrough-only fields for OpenAI channels.
RawOpenAIExtras map[string]json.RawMessage
}
// contentString returns a plain-text representation of a message content,
// handling both string and structured (Claude-style blocks) content.
func contentString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &blocks); err == nil {
out := ""
for _, b := range blocks {
if b.Text != "" {
out += b.Text
}
}
return out
}
return ""
}
// requestFromOpenAIChat parses an OpenAI chat body into the canonical form.
func requestFromOpenAIChat(body []byte) (*CanonicalRequest, error) {
var r openai.ChatRequest
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var msgs []openai.ChatMessage
if err := json.Unmarshal(r.Messages, &msgs); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Messages: msgs,
Stream: r.Stream,
Temperature: r.Temperature,
TopP: r.TopP,
Stop: parseStop(r.Stop),
ToolChoice: r.ToolChoice,
}
if r.MaxTokens != nil {
req.MaxTokens = r.MaxTokens
} else if r.MaxCompl != nil {
req.MaxTokens = r.MaxCompl
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
req.ResponseFormat = r.ResponseFmt
req.System = extractSystem(msgs)
req.RawOpenAIExtras = rawExtras(body, "model", "messages", "stream", "temperature", "top_p", "max_tokens", "max_completion_tokens", "stop", "tools", "tool_choice", "response_format", "user")
return req, nil
}
func requestFromClaude(body []byte) (*CanonicalRequest, error) {
var r claude.Request
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var msgs []openai.ChatMessage
// Claude messages: content can be a string or blocks; tool_use/tool_result
// blocks map to assistant.tool_calls and role=tool messages.
if err := claudeMessagesToChat(r.Messages, &msgs); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Messages: msgs,
Stream: r.Stream,
Temperature: r.Temperature,
TopP: r.TopP,
Stop: parseStop(r.StopSequences),
}
if r.MaxTokens > 0 {
mt := r.MaxTokens
req.MaxTokens = &mt
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
req.ToolChoice = r.ToolChoice
// Claude system can be a string or array of blocks.
req.System = contentString(r.System)
return req, nil
}
func requestFromResponses(body []byte) (*CanonicalRequest, error) {
var r responses.Request
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Stream: r.Stream,
}
// instructions → system.
req.System = contentString(r.Instructions)
// Parse input items into chat messages.
msgs, err := responsesInputToChat(r.Input)
if err != nil {
return nil, err
}
req.Messages = msgs
if r.MaxOutputTokens != nil {
req.MaxTokens = r.MaxOutputTokens
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
// output_format / text.format → response_format.
if len(r.OutputFormat) > 0 {
req.ResponseFormat = r.OutputFormat
} else if len(r.Text) > 0 {
var t struct {
Format json.RawMessage `json:"format"`
}
if json.Unmarshal(r.Text, &t) == nil && len(t.Format) > 0 {
req.ResponseFormat = t.Format
}
}
return req, nil
}
func extractSystem(msgs []openai.ChatMessage) string {
var parts []string
for _, m := range msgs {
if m.Role == "system" {
if s := contentString(m.Content); s != "" {
parts = append(parts, s)
}
}
}
return strings.Join(parts, "\n")
}
func parseStop(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var one string
if err := json.Unmarshal(raw, &one); err == nil {
return []string{one}
}
var many []string
if err := json.Unmarshal(raw, &many); err == nil {
return many
}
return nil
}
// rawExtras captures fields not otherwise modeled so they can be re-emitted
// on OpenAI passthrough-style conversions.
func rawExtras(body []byte, skip ...string) map[string]json.RawMessage {
var obj map[string]json.RawMessage
if json.Unmarshal(body, &obj) != nil {
return nil
}
for _, k := range skip {
delete(obj, k)
}
return obj
}
@@ -0,0 +1,269 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/openai"
)
// claudeMessagesToChat converts Claude messages JSON into OpenAI chat messages.
func claudeMessagesToChat(raw json.RawMessage, out *[]openai.ChatMessage) error {
var msgs []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &msgs); err != nil {
return err
}
for _, m := range msgs {
var text string
if err := json.Unmarshal(m.Content, &text); err == nil {
content, _ := json.Marshal(text)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: content})
continue
}
var blocks []map[string]json.RawMessage
if err := json.Unmarshal(m.Content, &blocks); err != nil {
return err
}
// Split blocks into: plain content parts, tool_use (→ tool_calls),
// and tool_result (→ separate role=tool messages).
var parts []json.RawMessage
var toolCalls []json.RawMessage
for _, b := range blocks {
var typ string
_ = json.Unmarshal(b["type"], &typ)
switch typ {
case "tool_use":
tc := map[string]any{
"id": rawString(b["id"]),
"type": "function",
"function": map[string]any{
"name": rawString(b["name"]),
"arguments": string(b["input"]),
},
}
encoded, _ := json.Marshal(tc)
toolCalls = append(toolCalls, encoded)
case "tool_result":
msg := openai.ChatMessage{
Role: "tool",
ToolCallID: rawString(b["tool_use_id"]),
}
// content may be a string or array of text blocks.
if b["content"] != nil {
var s string
if json.Unmarshal(b["content"], &s) == nil {
content, _ := json.Marshal(s)
msg.Content = content
} else {
var texts []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(b["content"], &texts) == nil {
var buf string
for _, t := range texts {
buf += t.Text
}
content, _ := json.Marshal(buf)
msg.Content = content
}
}
}
encoded, _ := json.Marshal(msg)
*out = append(*out, msg)
_ = encoded
default:
// text / image blocks → OpenAI content part.
part, err := claudeBlockToOpenAIContent(b)
if err != nil {
return err
}
if part != nil {
parts = append(parts, part)
}
}
}
if len(toolCalls) > 0 {
msg := openai.ChatMessage{Role: "assistant"}
if len(parts) > 0 {
content, _ := json.Marshal(partsToText(parts))
msg.Content = content
}
tcArr, _ := json.Marshal(toolCalls)
msg.ToolCalls = tcArr
*out = append(*out, msg)
} else if len(parts) > 0 {
if len(parts) == 1 {
// Collapse a single text part back to a plain string.
var s string
if json.Unmarshal(parts[0], &s) == nil {
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: parts[0]})
} else {
arr, _ := json.Marshal(parts)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
}
} else {
arr, _ := json.Marshal(parts)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
}
}
}
return nil
}
func claudeBlockToOpenAIContent(b map[string]json.RawMessage) (json.RawMessage, error) {
var typ string
_ = json.Unmarshal(b["type"], &typ)
switch typ {
case "text":
part := map[string]any{"type": "text", "text": rawString(b["text"])}
return json.Marshal(part)
case "image":
// b["source"] may not be present; be defensive.
if b["source"] != nil {
var src struct {
Type string `json:"type"`
URL string `json:"url"`
Data string `json:"data"`
MediaType string `json:"media_type"`
}
_ = json.Unmarshal(b["source"], &src)
if src.URL != "" {
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": src.URL}})
}
if src.Data != "" {
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + src.MediaType + ";base64," + src.Data}})
}
}
return nil, nil
default:
return nil, nil
}
}
func partsToText(parts []json.RawMessage) string {
var out string
for _, p := range parts {
var t struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(p, &t) == nil {
out += t.Text
}
}
return out
}
func rawString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return string(raw)
}
return s
}
// chatToClaudeMessages converts chat messages (with system already removed)
// into Claude Messages body.
func chatToClaudeMessages(msgs []openai.ChatMessage, system string) (map[string]any, error) {
claudeMsgs := []map[string]any{}
for _, m := range msgs {
if m.Role == "system" {
continue
}
// Tool calls on assistant messages → tool_use blocks.
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
content := []map[string]any{}
// Preserve any text content.
if text := contentString(m.Content); text != "" {
content = append(content, map[string]any{"type": "text", "text": text})
}
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(m.ToolCalls, &calls)
for _, c := range calls {
var input map[string]any
if err := json.Unmarshal(c.Function.Arguments, &input); err != nil {
input = map[string]any{"raw": string(c.Function.Arguments)}
}
content = append(content, map[string]any{
"type": "tool_use",
"id": c.ID,
"name": c.Function.Name,
"input": input,
})
}
claudeMsgs = append(claudeMsgs, map[string]any{"role": "assistant", "content": content})
continue
}
// Tool role messages → tool_result blocks.
if m.Role == "tool" {
content := []map[string]any{{
"type": "tool_result",
"tool_use_id": m.ToolCallID,
"content": contentString(m.Content),
}}
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": content})
continue
}
claudeMsgs = append(claudeMsgs, map[string]any{"role": m.Role, "content": contentString(m.Content)})
}
if len(claudeMsgs) == 0 {
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": "Hi"})
}
body := map[string]any{"messages": claudeMsgs}
if system != "" {
body["system"] = system
}
return body, nil
}
// toolsToClaude converts OpenAI function tools to Claude tools.
func toolsToClaude(tools []openai.Tool) []map[string]any {
out := []map[string]any{}
for _, t := range tools {
if t.Type != "" && t.Type != "function" {
continue
}
out = append(out, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": json.RawMessage(t.Function.Parameters),
})
}
return out
}
// claudeToolsToOpenAI converts Claude tools to OpenAI function tools.
func claudeToolsToOpenAI(tools json.RawMessage) []openai.Tool {
var list []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
if err := json.Unmarshal(tools, &list); err != nil {
return nil
}
out := []openai.Tool{}
for _, t := range list {
out = append(out, openai.Tool{
Type: "function",
Function: openai.FunctionTool{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
},
})
}
return out
}
+357
View File
@@ -0,0 +1,357 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
)
// ClientProtocol mirrors the gateway's Protocol but kept here to avoid an
// import cycle with the proxy package.
type ClientProtocol string
const (
ClientOpenAIChat ClientProtocol = "openai-chat"
ClientOpenAIResponses ClientProtocol = "openai-responses"
ClientAnthropic ClientProtocol = "anthropic"
)
// Request converts a request body from the client protocol to the channel
// provider's native format.
func Request(client ClientProtocol, provider string, body []byte, upstreamModel string) ([]byte, error) {
var canon *CanonicalRequest
var err error
switch client {
case ClientOpenAIChat:
canon, err = requestFromOpenAIChat(body)
case ClientOpenAIResponses:
canon, err = requestFromResponses(body)
case ClientAnthropic:
canon, err = requestFromClaude(body)
default:
return nil, jsonError("unsupported client protocol")
}
if err != nil {
return nil, err
}
canon.Model = upstreamModel
if canon.Model == "" {
canon.Model = ""
}
switch provider {
case "anthropic":
return canonicalToClaude(canon)
case "openai", "compatible":
return canonicalToOpenAIChat(canon)
default:
return nil, jsonError("unsupported provider")
}
}
// Response converts a non-stream upstream response into the client protocol.
func Response(client ClientProtocol, provider string, body []byte) ([]byte, error) {
if client == ClientOpenAIChat && provider == "openai" {
return body, nil // passthrough
}
if client == ClientOpenAIResponses && provider == "openai" {
return body, nil
}
if client == ClientAnthropic && provider == "anthropic" {
return body, nil
}
if provider == "anthropic" {
var mr claude.MessageResponse
if err := json.Unmarshal(body, &mr); err != nil {
return nil, err
}
switch client {
case ClientOpenAIChat:
return claudeResponseToChat(&mr)
case ClientOpenAIResponses:
return claudeResponseToResponses(&mr)
}
}
if provider == "openai" || provider == "compatible" {
var cc openai.ChatCompletion
if err := json.Unmarshal(body, &cc); err != nil {
return nil, err
}
switch client {
case ClientAnthropic:
return chatResponseToClaude(&cc)
}
}
return nil, jsonError("no conversion path")
}
// canonicalToOpenAIChat re-emits the canonical request as OpenAI chat JSON.
func canonicalToOpenAIChat(c *CanonicalRequest) ([]byte, error) {
obj := map[string]any{
"model": c.Model,
"messages": c.Messages,
}
if c.Stream {
obj["stream"] = true
}
if c.Temperature != nil {
obj["temperature"] = *c.Temperature
}
if c.TopP != nil {
obj["top_p"] = *c.TopP
}
if c.MaxTokens != nil {
obj["max_tokens"] = *c.MaxTokens
}
if len(c.Stop) > 0 {
obj["stop"] = c.Stop
}
if len(c.Tools) > 0 {
obj["tools"] = c.Tools
}
if len(c.ToolChoice) > 0 {
obj["tool_choice"] = json.RawMessage(c.ToolChoice)
}
if len(c.ResponseFormat) > 0 {
obj["response_format"] = json.RawMessage(c.ResponseFormat)
}
for k, v := range c.RawOpenAIExtras {
obj[k] = json.RawMessage(v)
}
return json.Marshal(obj)
}
// canonicalToClaude emits the canonical request as an Anthropic Messages body.
func canonicalToClaude(c *CanonicalRequest) ([]byte, error) {
body, err := chatToClaudeMessages(c.Messages, c.System)
if err != nil {
return nil, err
}
maxTokens := 4096
if c.MaxTokens != nil {
maxTokens = *c.MaxTokens
}
body["model"] = c.Model
body["max_tokens"] = maxTokens
if c.Stream {
body["stream"] = true
}
if c.Temperature != nil {
// Claude clamps temperature to [0,1].
t := *c.Temperature
if t > 1 {
t = 1
}
if t < 0 {
t = 0
}
body["temperature"] = t
}
if c.TopP != nil {
body["top_p"] = *c.TopP
}
if len(c.Stop) > 0 {
body["stop_sequences"] = c.Stop
}
if tools := toolsToClaude(c.Tools); len(tools) > 0 {
body["tools"] = tools
}
if len(c.ToolChoice) > 0 {
var tc json.RawMessage
if err := json.Unmarshal(c.ToolChoice, &tc); err == nil {
body["tool_choice"] = tc
}
}
return json.Marshal(body)
}
// claudeResponseToChat converts a Claude non-stream response to OpenAI chat.
func claudeResponseToChat(mr *claude.MessageResponse) ([]byte, error) {
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
_ = json.Unmarshal(mr.Content, &blocks)
content := ""
var toolCalls []map[string]any
for _, b := range blocks {
switch b.Type {
case "text":
if content == "" {
content = b.Text
} else {
content += b.Text
}
case "tool_use":
input, _ := json.Marshal(b.Input)
toolCalls = append(toolCalls, map[string]any{
"id": b.ID,
"type": "function",
"function": map[string]any{
"name": b.Name,
"arguments": string(input),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
finish := mapClaudeStopReason(mr.StopReason)
choices := []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}}
resp := map[string]any{
"id": "chatcmpl-" + mr.ID,
"object": "chat.completion",
"created": json.Number("0"),
"model": mr.Model,
"choices": choices,
}
if mr.Usage != nil {
resp["usage"] = map[string]any{
"prompt_tokens": mr.Usage.InputTokens,
"completion_tokens": mr.Usage.OutputTokens,
"total_tokens": mr.Usage.InputTokens + mr.Usage.OutputTokens,
}
}
return json.Marshal(resp)
}
// chatResponseToClaude converts an OpenAI non-stream response to Claude.
func chatResponseToClaude(cc *openai.ChatCompletion) ([]byte, error) {
content := []map[string]any{}
if len(cc.Choices) > 0 {
ch := cc.Choices[0]
if text := contentString(ch.Message.Content); text != "" {
content = append(content, map[string]any{"type": "text", "text": text})
}
if len(ch.Message.ToolCalls) > 0 {
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(ch.Message.ToolCalls, &calls)
for _, call := range calls {
var input map[string]any
_ = json.Unmarshal(call.Function.Arguments, &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": call.ID,
"name": call.Function.Name,
"input": input,
})
}
}
}
resp := map[string]any{
"id": mrID(cc.ID),
"type": "message",
"role": "assistant",
"model": cc.Model,
"content": content,
"stop_reason": mapChatStopReason(cc),
"usage": map[string]any{
"input_tokens": usageInt64(cc, true),
"output_tokens": usageInt64(cc, false),
},
}
return json.Marshal(resp)
}
// claudeResponseToResponses converts a Claude non-stream response to a
// Responses API response.
func claudeResponseToResponses(mr *claude.MessageResponse) ([]byte, error) {
chatCC := &openai.ChatCompletion{
ID: mr.ID,
Model: mr.Model,
Choices: []openai.ChatChoice{{FinishReason: mapClaudeStopReason(mr.StopReason)}},
}
// Reuse the chat conversion then re-shape into responses items.
chatBody, err := claudeResponseToChat(mr)
if err == nil {
var cc openai.ChatCompletion
if json.Unmarshal(chatBody, &cc) == nil {
chatCC = &cc
}
}
items, status := chatToResponsesOutput(chatCC)
resp := map[string]any{
"id": "resp_" + mr.ID,
"object": "response",
"created": json.Number("0"),
"model": mr.Model,
"status": status,
"output": items,
}
if mr.Usage != nil {
resp["usage"] = map[string]any{
"input_tokens": mr.Usage.InputTokens,
"output_tokens": mr.Usage.OutputTokens,
"total_tokens": mr.Usage.InputTokens + mr.Usage.OutputTokens,
"input_tokens_details": map[string]any{
"cached_tokens": mr.Usage.CacheReadInputTokens,
},
}
}
return json.Marshal(resp)
}
func mapClaudeStopReason(reason string) string {
switch reason {
case "end_turn":
return "stop"
case "max_tokens":
return "length"
case "stop_sequence":
return "stop"
case "tool_use":
return "tool_calls"
case "refusal":
return "content_filter"
default:
return "stop"
}
}
func mapChatStopReason(cc *openai.ChatCompletion) string {
if len(cc.Choices) == 0 {
return "end_turn"
}
switch cc.Choices[0].FinishReason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
func usageInt64(cc *openai.ChatCompletion, input bool) int64 {
if cc.Usage == nil {
return 0
}
if input {
return cc.Usage.PromptTokens
}
return cc.Usage.CompletionTokens
}
func mrID(id string) string {
if id == "" {
return "msg_unknown"
}
return id
}
+161
View File
@@ -0,0 +1,161 @@
package convert
import (
"encoding/json"
"errors"
"openteam/server/internal/proxy/openai"
)
// responsesInputToChat converts Responses API input items into chat messages.
func responsesInputToChat(raw json.RawMessage) ([]openai.ChatMessage, error) {
var msgs []openai.ChatMessage
// `input` may be a plain string.
var s string
if json.Unmarshal(raw, &s) == nil {
content, _ := json.Marshal(s)
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
return msgs, nil
}
// Or an array of content parts (text/image).
var parts []map[string]json.RawMessage
if json.Unmarshal(raw, &parts) == nil {
chat, err := contentPartsToChat(parts)
if err == nil {
return chat, nil
}
}
var items []map[string]json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
for _, item := range items {
var typ string
_ = json.Unmarshal(item["type"], &typ)
switch typ {
case "message":
var role string
_ = json.Unmarshal(item["role"], &role)
content, err := contentPartsToText(item["content"])
if err != nil {
continue
}
msgs = append(msgs, openai.ChatMessage{Role: role, Content: content})
case "function_call":
tc := map[string]any{
"id": rawString(item["call_id"]),
"type": "function",
"function": map[string]any{
"name": rawString(item["name"]),
"arguments": rawString(item["arguments"]),
},
}
tcArr, _ := json.Marshal([]any{tc})
msgs = append(msgs, openai.ChatMessage{Role: "assistant", ToolCalls: tcArr})
case "function_call_output":
content, _ := json.Marshal(rawString(item["output"]))
msgs = append(msgs, openai.ChatMessage{Role: "tool", ToolCallID: rawString(item["call_id"]), Content: content})
case "reasoning", "computer_call", "web_search_call":
// Not representable in chat; drop.
}
}
if len(msgs) == 0 {
content, _ := json.Marshal("")
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
}
return msgs, nil
}
// contentPartsToText flattens an array of content parts into a string.
func contentPartsToText(raw json.RawMessage) (json.RawMessage, error) {
var parts []map[string]json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
c, _ := json.Marshal(s)
return c, nil
}
return nil, err
}
var buf string
for _, p := range parts {
var typ string
_ = json.Unmarshal(p["type"], &typ)
switch typ {
case "input_text", "output_text", "text":
buf += rawString(p["text"])
}
}
c, _ := json.Marshal(buf)
return c, nil
}
func contentPartsToChat(parts []map[string]json.RawMessage) ([]openai.ChatMessage, error) {
out := []map[string]any{}
for _, p := range parts {
var typ string
_ = json.Unmarshal(p["type"], &typ)
switch typ {
case "input_text", "output_text", "text":
out = append(out, map[string]any{"type": "text", "text": rawString(p["text"])})
case "input_image":
out = append(out, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + rawString(p["media_type"]) + ";base64," + rawString(p["image_url"]),
},
})
}
}
if len(out) == 0 {
return nil, jsonError("empty content")
}
arr, _ := json.Marshal(out)
msg := openai.ChatMessage{Role: "user", Content: arr}
return []openai.ChatMessage{msg}, nil
}
// chatToResponsesOutput builds Responses output items from a chat completion.
func chatToResponsesOutput(cc *openai.ChatCompletion) ([]map[string]any, string) {
var items []map[string]any
var status = "completed"
if len(cc.Choices) == 0 {
return items, status
}
ch := cc.Choices[0]
if ch.FinishReason == "length" {
status = "incomplete"
}
content := []map[string]any{}
if text := contentString(ch.Message.Content); text != "" {
content = append(content, map[string]any{"type": "output_text", "text": text})
}
items = append(items, map[string]any{"type": "message", "role": "assistant", "content": content})
if len(ch.Message.ToolCalls) > 0 {
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
if json.Unmarshal(ch.Message.ToolCalls, &calls) == nil {
for _, call := range calls {
items = append(items, map[string]any{
"type": "function_call",
"call_id": call.ID,
"name": call.Function.Name,
"arguments": rawString(call.Function.Arguments),
})
}
}
}
return items, status
}
func jsonError(msg string) error {
return errors.New(msg)
}
+416
View File
@@ -0,0 +1,416 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/stream"
)
// Usage is the streaming token usage snapshot.
type Usage struct {
Input int64
Output int64
CacheRead int64
CacheCreation int64
}
// Translator converts SSE events from an upstream stream into client frames.
type Translator interface {
// Feed handles one upstream SSE event, returning client frames to write.
Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error)
// Finish is called at end-of-stream, returning final frames.
Finish() ([]stream.SSEEvent, error)
// Usage returns the latest known usage.
Usage() *Usage
}
// claudeToChatTranslator converts a Claude stream to OpenAI chat chunks.
type claudeToChatTranslator struct {
model string
usage *Usage
started bool
finishSent bool
toolCallIndex int
toolCallID string
toolCallName string
}
func (t *claudeToChatTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var e claude.StreamEvent
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
return nil, nil
}
var out []stream.SSEEvent
switch e.Type {
case "message_start":
if e.Message != nil {
var msg struct {
Model string `json:"model"`
}
_ = json.Unmarshal(e.Message, &msg)
t.model = msg.Model
}
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Role: "assistant"}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
t.started = true
case "content_block_start":
var cb struct {
Index int `json:"index"`
Block json.RawMessage `json:"content_block"`
}
_ = json.Unmarshal([]byte(ev.Data), &cb)
var block struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
_ = json.Unmarshal(cb.Block, &block)
if block.Type == "tool_use" {
t.toolCallIndex = cb.Index
t.toolCallID = block.ID
t.toolCallName = block.Name
tc, _ := json.Marshal([]map[string]any{{
"index": cb.Index, "id": block.ID, "type": "function",
"function": map[string]any{"name": block.Name, "arguments": ""},
}})
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
}
case "content_block_delta":
var d struct {
Index int `json:"index"`
Delta json.RawMessage `json:"delta"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
var delta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.Type == "text_delta" && delta.Text != "" {
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Content: delta.Text}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
} else if delta.Type == "input_json_delta" && delta.PartialJSON != "" {
tc, _ := json.Marshal([]map[string]any{{
"index": d.Index, "function": map[string]any{"arguments": delta.PartialJSON},
}})
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
}
case "message_delta":
var d struct {
Delta json.RawMessage `json:"delta"`
Usage json.RawMessage `json:"usage"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
if len(d.Usage) > 0 {
var u claude.Usage
if json.Unmarshal(d.Usage, &u) == nil {
t.usage = &Usage{
Input: u.InputTokens, Output: u.OutputTokens,
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
}
}
}
if len(d.Delta) > 0 && !t.finishSent {
var delta struct {
StopReason string `json:"stop_reason"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.StopReason != "" {
reason := mapClaudeStopReason(delta.StopReason)
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
t.finishSent = true
}
}
}
return out, nil
}
func (t *claudeToChatTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.finishSent {
reason := "stop"
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
})
t.finishSent = true
return []stream.SSEEvent{{Data: string(chunk)}, {Data: "[DONE]"}}, nil
}
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
}
func (t *claudeToChatTranslator) Usage() *Usage { return t.usage }
// chatToClaudeTranslator converts an OpenAI chat stream to Claude events.
type chatToClaudeTranslator struct {
usage *Usage
started bool
openBlock bool
blockType string
toolIndex int
finishSent bool
}
func (t *chatToClaudeTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var chunk openai.ChatChunk
if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
return nil, nil
}
if chunk.Usage != nil {
t.usage = &Usage{Input: chunk.Usage.PromptTokens, Output: chunk.Usage.CompletionTokens}
}
var out []stream.SSEEvent
if len(chunk.Choices) == 0 {
return out, nil
}
ch := chunk.Choices[0]
if !t.started {
msg, _ := json.Marshal(map[string]any{
"id": "msg_stream", "type": "message", "role": "assistant",
"model": chunk.Model, "content": []any{},
})
start, _ := json.Marshal(map[string]any{"type": "message_start", "message": json.RawMessage(msg)})
out = append(out, stream.SSEEvent{Data: string(start)})
t.started = true
}
if ch.Delta.Content != "" {
if !t.openBlock || t.blockType != "text" {
start, _ := json.Marshal(map[string]any{
"type": "content_block_start", "index": 0,
"content_block": map[string]any{"type": "text", "text": ""},
})
out = append(out, stream.SSEEvent{Data: string(start)})
t.openBlock = true
t.blockType = "text"
t.toolIndex = 0
}
delta, _ := json.Marshal(map[string]any{
"type": "content_block_delta", "index": 0,
"delta": map[string]any{"type": "text_delta", "text": ch.Delta.Content},
})
out = append(out, stream.SSEEvent{Data: string(delta)})
}
if len(ch.Delta.ToolCalls) > 0 {
var calls []struct {
Index *int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(ch.Delta.ToolCalls, &calls)
for _, call := range calls {
idx := 0
if call.Index != nil {
idx = *call.Index
}
if !t.openBlock || t.blockType != "tool_use" || idx != t.toolIndex {
start, _ := json.Marshal(map[string]any{
"type": "content_block_start", "index": idx,
"content_block": map[string]any{
"type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": map[string]any{},
},
})
out = append(out, stream.SSEEvent{Data: string(start)})
t.openBlock = true
t.blockType = "tool_use"
t.toolIndex = idx
}
if call.Function.Arguments != "" {
delta, _ := json.Marshal(map[string]any{
"type": "content_block_delta", "index": idx,
"delta": map[string]any{"type": "input_json_delta", "partial_json": call.Function.Arguments},
})
out = append(out, stream.SSEEvent{Data: string(delta)})
}
}
}
if ch.FinishReason != nil && !t.finishSent {
reason := mapChatStopReasonToClaude(*ch.FinishReason)
md, _ := json.Marshal(map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": reason, "stop_sequence": nil},
})
out = append(out, stream.SSEEvent{Data: string(md)})
t.finishSent = true
}
return out, nil
}
func (t *chatToClaudeTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.finishSent {
md, _ := json.Marshal(map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
})
t.finishSent = true
return []stream.SSEEvent{{Data: string(md)}, {Data: "{\"type\":\"message_stop\"}"}}, nil
}
return []stream.SSEEvent{{Data: "{\"type\":\"message_stop\"}"}}, nil
}
func (t *chatToClaudeTranslator) Usage() *Usage { return t.usage }
// claudeToResponsesTranslator converts a Claude stream to Responses events.
type claudeToResponsesTranslator struct {
usage *Usage
model string
completed bool
}
func (t *claudeToResponsesTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var e claude.StreamEvent
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
return nil, nil
}
var out []stream.SSEEvent
switch e.Type {
case "message_start":
if e.Message != nil {
var msg struct {
Model string `json:"model"`
}
_ = json.Unmarshal(e.Message, &msg)
t.model = msg.Model
}
created, _ := json.Marshal(map[string]any{
"type": "response.created",
"response": map[string]any{"id": "resp_stream", "object": "response", "status": "in_progress", "model": t.model, "output": []any{}},
})
out = append(out, stream.SSEEvent{Data: string(created)})
case "content_block_delta":
var d struct {
Index int `json:"index"`
Delta json.RawMessage `json:"delta"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
var delta struct {
Type string `json:"type"`
Text string `json:"text"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.Type == "text_delta" && delta.Text != "" {
item, _ := json.Marshal(map[string]any{
"type": "response.output_text.delta", "item_id": "msg_stream", "output_index": 0,
"delta": delta.Text,
})
out = append(out, stream.SSEEvent{Data: string(item)})
}
case "message_delta":
var d struct {
Usage json.RawMessage `json:"usage"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
if len(d.Usage) > 0 {
var u claude.Usage
if json.Unmarshal(d.Usage, &u) == nil {
t.usage = &Usage{
Input: u.InputTokens, Output: u.OutputTokens,
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
}
}
}
if !t.completed {
item, _ := json.Marshal(map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
},
})
out = append(out, stream.SSEEvent{Data: string(item)})
t.completed = true
}
}
return out, nil
}
func (t *claudeToResponsesTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.completed {
item, _ := json.Marshal(map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
},
})
t.completed = true
return []stream.SSEEvent{{Data: string(item)}, {Data: "[DONE]"}}, nil
}
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
}
func (t *claudeToResponsesTranslator) Usage() *Usage { return t.usage }
func mapChatStopReasonToClaude(reason string) string {
switch reason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
// NewTranslator returns the stream translator for a client protocol +
// upstream provider pair, or nil for passthrough (no translation needed).
func NewTranslator(client ClientProtocol, provider string) Translator {
switch client {
case ClientOpenAIChat:
if provider == "anthropic" {
return &claudeToChatTranslator{}
}
case ClientOpenAIResponses:
if provider == "anthropic" {
return &claudeToResponsesTranslator{}
}
case ClientAnthropic:
if provider == "openai" || provider == "compatible" {
return &chatToClaudeTranslator{}
}
}
return nil
}