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>
197 lines
4.9 KiB
Go
197 lines
4.9 KiB
Go
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
|
|
}
|