Files
openteam/server/internal/proxy/convert/convert.go
T
SakurasanandClaude Sonnet 5 d0e31b198f 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>
2026-08-15 21:05:02 +08:00

358 lines
8.7 KiB
Go

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
}