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,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
|
||||
}
|
||||
Reference in New Issue
Block a user