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>
358 lines
8.7 KiB
Go
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
|
|
}
|