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:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user