Files
openteam/server/internal/proxy/convert/canonical.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

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
}