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,50 @@
|
||||
package claude
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Request is an Anthropic Messages request.
|
||||
type Request struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
System json.RawMessage `json:"system"`
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
TopK *int `json:"top_k"`
|
||||
StopSequences json.RawMessage `json:"stop_sequences"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// Usage is the Claude usage block.
|
||||
type Usage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// MessageResponse is the non-stream response.
|
||||
type MessageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence string `json:"stop_sequence"`
|
||||
Usage *Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// StreamEvent is one event in the Claude streaming event sequence.
|
||||
type StreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Message json.RawMessage `json:"message,omitempty"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
Delta json.RawMessage `json:"delta,omitempty"`
|
||||
Usage json.RawMessage `json:"usage,omitempty"`
|
||||
ContentBlock json.RawMessage `json:"content_block,omitempty"`
|
||||
Error json.RawMessage `json:"error,omitempty"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"openteam/server/internal/proxy/openai"
|
||||
)
|
||||
|
||||
// claudeMessagesToChat converts Claude messages JSON into OpenAI chat messages.
|
||||
func claudeMessagesToChat(raw json.RawMessage, out *[]openai.ChatMessage) error {
|
||||
var msgs []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msgs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range msgs {
|
||||
var text string
|
||||
if err := json.Unmarshal(m.Content, &text); err == nil {
|
||||
content, _ := json.Marshal(text)
|
||||
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: content})
|
||||
continue
|
||||
}
|
||||
var blocks []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(m.Content, &blocks); err != nil {
|
||||
return err
|
||||
}
|
||||
// Split blocks into: plain content parts, tool_use (→ tool_calls),
|
||||
// and tool_result (→ separate role=tool messages).
|
||||
var parts []json.RawMessage
|
||||
var toolCalls []json.RawMessage
|
||||
for _, b := range blocks {
|
||||
var typ string
|
||||
_ = json.Unmarshal(b["type"], &typ)
|
||||
switch typ {
|
||||
case "tool_use":
|
||||
tc := map[string]any{
|
||||
"id": rawString(b["id"]),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": rawString(b["name"]),
|
||||
"arguments": string(b["input"]),
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(tc)
|
||||
toolCalls = append(toolCalls, encoded)
|
||||
case "tool_result":
|
||||
msg := openai.ChatMessage{
|
||||
Role: "tool",
|
||||
ToolCallID: rawString(b["tool_use_id"]),
|
||||
}
|
||||
// content may be a string or array of text blocks.
|
||||
if b["content"] != nil {
|
||||
var s string
|
||||
if json.Unmarshal(b["content"], &s) == nil {
|
||||
content, _ := json.Marshal(s)
|
||||
msg.Content = content
|
||||
} else {
|
||||
var texts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(b["content"], &texts) == nil {
|
||||
var buf string
|
||||
for _, t := range texts {
|
||||
buf += t.Text
|
||||
}
|
||||
content, _ := json.Marshal(buf)
|
||||
msg.Content = content
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(msg)
|
||||
*out = append(*out, msg)
|
||||
_ = encoded
|
||||
default:
|
||||
// text / image blocks → OpenAI content part.
|
||||
part, err := claudeBlockToOpenAIContent(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if part != nil {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg := openai.ChatMessage{Role: "assistant"}
|
||||
if len(parts) > 0 {
|
||||
content, _ := json.Marshal(partsToText(parts))
|
||||
msg.Content = content
|
||||
}
|
||||
tcArr, _ := json.Marshal(toolCalls)
|
||||
msg.ToolCalls = tcArr
|
||||
*out = append(*out, msg)
|
||||
} else if len(parts) > 0 {
|
||||
if len(parts) == 1 {
|
||||
// Collapse a single text part back to a plain string.
|
||||
var s string
|
||||
if json.Unmarshal(parts[0], &s) == nil {
|
||||
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: parts[0]})
|
||||
} else {
|
||||
arr, _ := json.Marshal(parts)
|
||||
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
|
||||
}
|
||||
} else {
|
||||
arr, _ := json.Marshal(parts)
|
||||
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claudeBlockToOpenAIContent(b map[string]json.RawMessage) (json.RawMessage, error) {
|
||||
var typ string
|
||||
_ = json.Unmarshal(b["type"], &typ)
|
||||
switch typ {
|
||||
case "text":
|
||||
part := map[string]any{"type": "text", "text": rawString(b["text"])}
|
||||
return json.Marshal(part)
|
||||
case "image":
|
||||
// b["source"] may not be present; be defensive.
|
||||
if b["source"] != nil {
|
||||
var src struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Data string `json:"data"`
|
||||
MediaType string `json:"media_type"`
|
||||
}
|
||||
_ = json.Unmarshal(b["source"], &src)
|
||||
if src.URL != "" {
|
||||
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": src.URL}})
|
||||
}
|
||||
if src.Data != "" {
|
||||
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + src.MediaType + ";base64," + src.Data}})
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func partsToText(parts []json.RawMessage) string {
|
||||
var out string
|
||||
for _, p := range parts {
|
||||
var t struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if json.Unmarshal(p, &t) == nil {
|
||||
out += t.Text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rawString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// chatToClaudeMessages converts chat messages (with system already removed)
|
||||
// into Claude Messages body.
|
||||
func chatToClaudeMessages(msgs []openai.ChatMessage, system string) (map[string]any, error) {
|
||||
claudeMsgs := []map[string]any{}
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
// Tool calls on assistant messages → tool_use blocks.
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
content := []map[string]any{}
|
||||
// Preserve any text content.
|
||||
if text := contentString(m.Content); text != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": text})
|
||||
}
|
||||
var calls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
_ = json.Unmarshal(m.ToolCalls, &calls)
|
||||
for _, c := range calls {
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(c.Function.Arguments, &input); err != nil {
|
||||
input = map[string]any{"raw": string(c.Function.Arguments)}
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": c.ID,
|
||||
"name": c.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
claudeMsgs = append(claudeMsgs, map[string]any{"role": "assistant", "content": content})
|
||||
continue
|
||||
}
|
||||
// Tool role messages → tool_result blocks.
|
||||
if m.Role == "tool" {
|
||||
content := []map[string]any{{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": m.ToolCallID,
|
||||
"content": contentString(m.Content),
|
||||
}}
|
||||
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": content})
|
||||
continue
|
||||
}
|
||||
claudeMsgs = append(claudeMsgs, map[string]any{"role": m.Role, "content": contentString(m.Content)})
|
||||
}
|
||||
if len(claudeMsgs) == 0 {
|
||||
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": "Hi"})
|
||||
}
|
||||
body := map[string]any{"messages": claudeMsgs}
|
||||
if system != "" {
|
||||
body["system"] = system
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// toolsToClaude converts OpenAI function tools to Claude tools.
|
||||
func toolsToClaude(tools []openai.Tool) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, t := range tools {
|
||||
if t.Type != "" && t.Type != "function" {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"input_schema": json.RawMessage(t.Function.Parameters),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// claudeToolsToOpenAI converts Claude tools to OpenAI function tools.
|
||||
func claudeToolsToOpenAI(tools json.RawMessage) []openai.Tool {
|
||||
var list []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"input_schema"`
|
||||
}
|
||||
if err := json.Unmarshal(tools, &list); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []openai.Tool{}
|
||||
for _, t := range list {
|
||||
out = append(out, openai.Tool{
|
||||
Type: "function",
|
||||
Function: openai.FunctionTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.InputSchema,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"openteam/server/internal/proxy/openai"
|
||||
)
|
||||
|
||||
// responsesInputToChat converts Responses API input items into chat messages.
|
||||
func responsesInputToChat(raw json.RawMessage) ([]openai.ChatMessage, error) {
|
||||
var msgs []openai.ChatMessage
|
||||
|
||||
// `input` may be a plain string.
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
content, _ := json.Marshal(s)
|
||||
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// Or an array of content parts (text/image).
|
||||
var parts []map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &parts) == nil {
|
||||
chat, err := contentPartsToChat(parts)
|
||||
if err == nil {
|
||||
return chat, nil
|
||||
}
|
||||
}
|
||||
|
||||
var items []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
var typ string
|
||||
_ = json.Unmarshal(item["type"], &typ)
|
||||
switch typ {
|
||||
case "message":
|
||||
var role string
|
||||
_ = json.Unmarshal(item["role"], &role)
|
||||
content, err := contentPartsToText(item["content"])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, openai.ChatMessage{Role: role, Content: content})
|
||||
case "function_call":
|
||||
tc := map[string]any{
|
||||
"id": rawString(item["call_id"]),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": rawString(item["name"]),
|
||||
"arguments": rawString(item["arguments"]),
|
||||
},
|
||||
}
|
||||
tcArr, _ := json.Marshal([]any{tc})
|
||||
msgs = append(msgs, openai.ChatMessage{Role: "assistant", ToolCalls: tcArr})
|
||||
case "function_call_output":
|
||||
content, _ := json.Marshal(rawString(item["output"]))
|
||||
msgs = append(msgs, openai.ChatMessage{Role: "tool", ToolCallID: rawString(item["call_id"]), Content: content})
|
||||
case "reasoning", "computer_call", "web_search_call":
|
||||
// Not representable in chat; drop.
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
content, _ := json.Marshal("")
|
||||
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// contentPartsToText flattens an array of content parts into a string.
|
||||
func contentPartsToText(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var parts []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
c, _ := json.Marshal(s)
|
||||
return c, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var buf string
|
||||
for _, p := range parts {
|
||||
var typ string
|
||||
_ = json.Unmarshal(p["type"], &typ)
|
||||
switch typ {
|
||||
case "input_text", "output_text", "text":
|
||||
buf += rawString(p["text"])
|
||||
}
|
||||
}
|
||||
c, _ := json.Marshal(buf)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func contentPartsToChat(parts []map[string]json.RawMessage) ([]openai.ChatMessage, error) {
|
||||
out := []map[string]any{}
|
||||
for _, p := range parts {
|
||||
var typ string
|
||||
_ = json.Unmarshal(p["type"], &typ)
|
||||
switch typ {
|
||||
case "input_text", "output_text", "text":
|
||||
out = append(out, map[string]any{"type": "text", "text": rawString(p["text"])})
|
||||
case "input_image":
|
||||
out = append(out, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + rawString(p["media_type"]) + ";base64," + rawString(p["image_url"]),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, jsonError("empty content")
|
||||
}
|
||||
arr, _ := json.Marshal(out)
|
||||
msg := openai.ChatMessage{Role: "user", Content: arr}
|
||||
return []openai.ChatMessage{msg}, nil
|
||||
}
|
||||
|
||||
// chatToResponsesOutput builds Responses output items from a chat completion.
|
||||
func chatToResponsesOutput(cc *openai.ChatCompletion) ([]map[string]any, string) {
|
||||
var items []map[string]any
|
||||
var status = "completed"
|
||||
if len(cc.Choices) == 0 {
|
||||
return items, status
|
||||
}
|
||||
ch := cc.Choices[0]
|
||||
if ch.FinishReason == "length" {
|
||||
status = "incomplete"
|
||||
}
|
||||
content := []map[string]any{}
|
||||
if text := contentString(ch.Message.Content); text != "" {
|
||||
content = append(content, map[string]any{"type": "output_text", "text": text})
|
||||
}
|
||||
items = append(items, map[string]any{"type": "message", "role": "assistant", "content": content})
|
||||
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"`
|
||||
}
|
||||
if json.Unmarshal(ch.Message.ToolCalls, &calls) == nil {
|
||||
for _, call := range calls {
|
||||
items = append(items, map[string]any{
|
||||
"type": "function_call",
|
||||
"call_id": call.ID,
|
||||
"name": call.Function.Name,
|
||||
"arguments": rawString(call.Function.Arguments),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, status
|
||||
}
|
||||
|
||||
func jsonError(msg string) error {
|
||||
return errors.New(msg)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"openteam/server/internal/proxy/claude"
|
||||
"openteam/server/internal/proxy/openai"
|
||||
"openteam/server/internal/proxy/stream"
|
||||
)
|
||||
|
||||
// Usage is the streaming token usage snapshot.
|
||||
type Usage struct {
|
||||
Input int64
|
||||
Output int64
|
||||
CacheRead int64
|
||||
CacheCreation int64
|
||||
}
|
||||
|
||||
// Translator converts SSE events from an upstream stream into client frames.
|
||||
type Translator interface {
|
||||
// Feed handles one upstream SSE event, returning client frames to write.
|
||||
Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error)
|
||||
// Finish is called at end-of-stream, returning final frames.
|
||||
Finish() ([]stream.SSEEvent, error)
|
||||
// Usage returns the latest known usage.
|
||||
Usage() *Usage
|
||||
}
|
||||
|
||||
// claudeToChatTranslator converts a Claude stream to OpenAI chat chunks.
|
||||
type claudeToChatTranslator struct {
|
||||
model string
|
||||
usage *Usage
|
||||
started bool
|
||||
finishSent bool
|
||||
toolCallIndex int
|
||||
toolCallID string
|
||||
toolCallName string
|
||||
}
|
||||
|
||||
func (t *claudeToChatTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
|
||||
if ev.Done {
|
||||
return nil, nil
|
||||
}
|
||||
var e claude.StreamEvent
|
||||
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []stream.SSEEvent
|
||||
|
||||
switch e.Type {
|
||||
case "message_start":
|
||||
if e.Message != nil {
|
||||
var msg struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Message, &msg)
|
||||
t.model = msg.Model
|
||||
}
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Role: "assistant"}}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(chunk)})
|
||||
t.started = true
|
||||
|
||||
case "content_block_start":
|
||||
var cb struct {
|
||||
Index int `json:"index"`
|
||||
Block json.RawMessage `json:"content_block"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ev.Data), &cb)
|
||||
var block struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.Unmarshal(cb.Block, &block)
|
||||
if block.Type == "tool_use" {
|
||||
t.toolCallIndex = cb.Index
|
||||
t.toolCallID = block.ID
|
||||
t.toolCallName = block.Name
|
||||
tc, _ := json.Marshal([]map[string]any{{
|
||||
"index": cb.Index, "id": block.ID, "type": "function",
|
||||
"function": map[string]any{"name": block.Name, "arguments": ""},
|
||||
}})
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(chunk)})
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
var d struct {
|
||||
Index int `json:"index"`
|
||||
Delta json.RawMessage `json:"delta"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ev.Data), &d)
|
||||
var delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
PartialJSON string `json:"partial_json"`
|
||||
}
|
||||
_ = json.Unmarshal(d.Delta, &delta)
|
||||
if delta.Type == "text_delta" && delta.Text != "" {
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Content: delta.Text}}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(chunk)})
|
||||
} else if delta.Type == "input_json_delta" && delta.PartialJSON != "" {
|
||||
tc, _ := json.Marshal([]map[string]any{{
|
||||
"index": d.Index, "function": map[string]any{"arguments": delta.PartialJSON},
|
||||
}})
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(chunk)})
|
||||
}
|
||||
|
||||
case "message_delta":
|
||||
var d struct {
|
||||
Delta json.RawMessage `json:"delta"`
|
||||
Usage json.RawMessage `json:"usage"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ev.Data), &d)
|
||||
if len(d.Usage) > 0 {
|
||||
var u claude.Usage
|
||||
if json.Unmarshal(d.Usage, &u) == nil {
|
||||
t.usage = &Usage{
|
||||
Input: u.InputTokens, Output: u.OutputTokens,
|
||||
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.Delta) > 0 && !t.finishSent {
|
||||
var delta struct {
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
_ = json.Unmarshal(d.Delta, &delta)
|
||||
if delta.StopReason != "" {
|
||||
reason := mapClaudeStopReason(delta.StopReason)
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(chunk)})
|
||||
t.finishSent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *claudeToChatTranslator) Finish() ([]stream.SSEEvent, error) {
|
||||
if !t.finishSent {
|
||||
reason := "stop"
|
||||
chunk, _ := json.Marshal(openai.ChatChunk{
|
||||
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
|
||||
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
|
||||
})
|
||||
t.finishSent = true
|
||||
return []stream.SSEEvent{{Data: string(chunk)}, {Data: "[DONE]"}}, nil
|
||||
}
|
||||
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
|
||||
}
|
||||
|
||||
func (t *claudeToChatTranslator) Usage() *Usage { return t.usage }
|
||||
|
||||
// chatToClaudeTranslator converts an OpenAI chat stream to Claude events.
|
||||
type chatToClaudeTranslator struct {
|
||||
usage *Usage
|
||||
started bool
|
||||
openBlock bool
|
||||
blockType string
|
||||
toolIndex int
|
||||
finishSent bool
|
||||
}
|
||||
|
||||
func (t *chatToClaudeTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
|
||||
if ev.Done {
|
||||
return nil, nil
|
||||
}
|
||||
var chunk openai.ChatChunk
|
||||
if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
t.usage = &Usage{Input: chunk.Usage.PromptTokens, Output: chunk.Usage.CompletionTokens}
|
||||
}
|
||||
var out []stream.SSEEvent
|
||||
if len(chunk.Choices) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ch := chunk.Choices[0]
|
||||
|
||||
if !t.started {
|
||||
msg, _ := json.Marshal(map[string]any{
|
||||
"id": "msg_stream", "type": "message", "role": "assistant",
|
||||
"model": chunk.Model, "content": []any{},
|
||||
})
|
||||
start, _ := json.Marshal(map[string]any{"type": "message_start", "message": json.RawMessage(msg)})
|
||||
out = append(out, stream.SSEEvent{Data: string(start)})
|
||||
t.started = true
|
||||
}
|
||||
|
||||
if ch.Delta.Content != "" {
|
||||
if !t.openBlock || t.blockType != "text" {
|
||||
start, _ := json.Marshal(map[string]any{
|
||||
"type": "content_block_start", "index": 0,
|
||||
"content_block": map[string]any{"type": "text", "text": ""},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(start)})
|
||||
t.openBlock = true
|
||||
t.blockType = "text"
|
||||
t.toolIndex = 0
|
||||
}
|
||||
delta, _ := json.Marshal(map[string]any{
|
||||
"type": "content_block_delta", "index": 0,
|
||||
"delta": map[string]any{"type": "text_delta", "text": ch.Delta.Content},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(delta)})
|
||||
}
|
||||
|
||||
if len(ch.Delta.ToolCalls) > 0 {
|
||||
var calls []struct {
|
||||
Index *int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
_ = json.Unmarshal(ch.Delta.ToolCalls, &calls)
|
||||
for _, call := range calls {
|
||||
idx := 0
|
||||
if call.Index != nil {
|
||||
idx = *call.Index
|
||||
}
|
||||
if !t.openBlock || t.blockType != "tool_use" || idx != t.toolIndex {
|
||||
start, _ := json.Marshal(map[string]any{
|
||||
"type": "content_block_start", "index": idx,
|
||||
"content_block": map[string]any{
|
||||
"type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": map[string]any{},
|
||||
},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(start)})
|
||||
t.openBlock = true
|
||||
t.blockType = "tool_use"
|
||||
t.toolIndex = idx
|
||||
}
|
||||
if call.Function.Arguments != "" {
|
||||
delta, _ := json.Marshal(map[string]any{
|
||||
"type": "content_block_delta", "index": idx,
|
||||
"delta": map[string]any{"type": "input_json_delta", "partial_json": call.Function.Arguments},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(delta)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ch.FinishReason != nil && !t.finishSent {
|
||||
reason := mapChatStopReasonToClaude(*ch.FinishReason)
|
||||
md, _ := json.Marshal(map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": reason, "stop_sequence": nil},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(md)})
|
||||
t.finishSent = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *chatToClaudeTranslator) Finish() ([]stream.SSEEvent, error) {
|
||||
if !t.finishSent {
|
||||
md, _ := json.Marshal(map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
})
|
||||
t.finishSent = true
|
||||
return []stream.SSEEvent{{Data: string(md)}, {Data: "{\"type\":\"message_stop\"}"}}, nil
|
||||
}
|
||||
return []stream.SSEEvent{{Data: "{\"type\":\"message_stop\"}"}}, nil
|
||||
}
|
||||
|
||||
func (t *chatToClaudeTranslator) Usage() *Usage { return t.usage }
|
||||
|
||||
// claudeToResponsesTranslator converts a Claude stream to Responses events.
|
||||
type claudeToResponsesTranslator struct {
|
||||
usage *Usage
|
||||
model string
|
||||
completed bool
|
||||
}
|
||||
|
||||
func (t *claudeToResponsesTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
|
||||
if ev.Done {
|
||||
return nil, nil
|
||||
}
|
||||
var e claude.StreamEvent
|
||||
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []stream.SSEEvent
|
||||
switch e.Type {
|
||||
case "message_start":
|
||||
if e.Message != nil {
|
||||
var msg struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Message, &msg)
|
||||
t.model = msg.Model
|
||||
}
|
||||
created, _ := json.Marshal(map[string]any{
|
||||
"type": "response.created",
|
||||
"response": map[string]any{"id": "resp_stream", "object": "response", "status": "in_progress", "model": t.model, "output": []any{}},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(created)})
|
||||
case "content_block_delta":
|
||||
var d struct {
|
||||
Index int `json:"index"`
|
||||
Delta json.RawMessage `json:"delta"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ev.Data), &d)
|
||||
var delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.Unmarshal(d.Delta, &delta)
|
||||
if delta.Type == "text_delta" && delta.Text != "" {
|
||||
item, _ := json.Marshal(map[string]any{
|
||||
"type": "response.output_text.delta", "item_id": "msg_stream", "output_index": 0,
|
||||
"delta": delta.Text,
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(item)})
|
||||
}
|
||||
case "message_delta":
|
||||
var d struct {
|
||||
Usage json.RawMessage `json:"usage"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(ev.Data), &d)
|
||||
if len(d.Usage) > 0 {
|
||||
var u claude.Usage
|
||||
if json.Unmarshal(d.Usage, &u) == nil {
|
||||
t.usage = &Usage{
|
||||
Input: u.InputTokens, Output: u.OutputTokens,
|
||||
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
if !t.completed {
|
||||
item, _ := json.Marshal(map[string]any{
|
||||
"type": "response.completed",
|
||||
"response": map[string]any{
|
||||
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
|
||||
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
|
||||
},
|
||||
})
|
||||
out = append(out, stream.SSEEvent{Data: string(item)})
|
||||
t.completed = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *claudeToResponsesTranslator) Finish() ([]stream.SSEEvent, error) {
|
||||
if !t.completed {
|
||||
item, _ := json.Marshal(map[string]any{
|
||||
"type": "response.completed",
|
||||
"response": map[string]any{
|
||||
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
|
||||
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
|
||||
},
|
||||
})
|
||||
t.completed = true
|
||||
return []stream.SSEEvent{{Data: string(item)}, {Data: "[DONE]"}}, nil
|
||||
}
|
||||
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
|
||||
}
|
||||
|
||||
func (t *claudeToResponsesTranslator) Usage() *Usage { return t.usage }
|
||||
|
||||
func mapChatStopReasonToClaude(reason string) string {
|
||||
switch reason {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// NewTranslator returns the stream translator for a client protocol +
|
||||
// upstream provider pair, or nil for passthrough (no translation needed).
|
||||
func NewTranslator(client ClientProtocol, provider string) Translator {
|
||||
switch client {
|
||||
case ClientOpenAIChat:
|
||||
if provider == "anthropic" {
|
||||
return &claudeToChatTranslator{}
|
||||
}
|
||||
case ClientOpenAIResponses:
|
||||
if provider == "anthropic" {
|
||||
return &claudeToResponsesTranslator{}
|
||||
}
|
||||
case ClientAnthropic:
|
||||
if provider == "openai" || provider == "compatible" {
|
||||
return &chatToClaudeTranslator{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"openteam/server/internal/proxy/convert"
|
||||
)
|
||||
|
||||
// convertRequest translates a request body from the client protocol to the
|
||||
// channel provider's native format.
|
||||
func convertRequest(route Route, provider string, body []byte, upstreamModel string) ([]byte, error) {
|
||||
return convert.Request(clientProto(route), provider, body, upstreamModel)
|
||||
}
|
||||
|
||||
// convertResponse translates a non-stream upstream response back to the
|
||||
// client protocol.
|
||||
func convertResponse(route Route, provider string, body []byte) ([]byte, error) {
|
||||
return convert.Response(clientProto(route), provider, body)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/apikey"
|
||||
"openteam/server/internal/billing"
|
||||
"openteam/server/internal/channel"
|
||||
"openteam/server/internal/config"
|
||||
"openteam/server/internal/pkg/ratelimit"
|
||||
"openteam/server/internal/usage"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// Protocol identifies the client-facing API protocol.
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
ProtocolOpenAIChat Protocol = "openai-chat"
|
||||
ProtocolOpenAIResponses Protocol = "openai-responses"
|
||||
ProtocolAnthropic Protocol = "anthropic"
|
||||
)
|
||||
|
||||
// Route describes one proxied endpoint.
|
||||
type Route struct {
|
||||
Protocol Protocol
|
||||
UpstreamPath string // suffix after base URL, e.g. /v1/chat/completions
|
||||
NativeProvider string // provider type that matches this protocol ("openai" | "anthropic")
|
||||
}
|
||||
|
||||
var Routes = []Route{
|
||||
{Protocol: ProtocolOpenAIChat, UpstreamPath: "/v1/chat/completions", NativeProvider: "openai"},
|
||||
{Protocol: ProtocolOpenAIResponses, UpstreamPath: "/v1/responses", NativeProvider: "openai"},
|
||||
{Protocol: ProtocolAnthropic, UpstreamPath: "/v1/messages", NativeProvider: "anthropic"},
|
||||
}
|
||||
|
||||
const maxBodyBytes = 16 << 20 // 16 MiB
|
||||
|
||||
type Gateway struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
channel *channel.Service
|
||||
billing *billing.Service
|
||||
usage *usage.Service
|
||||
apiKeys *apikey.Service
|
||||
limiter *ratelimit.Limiter
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewGateway(db *gorm.DB, cfg *config.Config, log *zap.Logger,
|
||||
ch *channel.Service, bill *billing.Service, use *usage.Service, ak *apikey.Service) *Gateway {
|
||||
return &Gateway{
|
||||
db: db, cfg: cfg, log: log,
|
||||
channel: ch, billing: bill, usage: use, apiKeys: ak,
|
||||
limiter: ratelimit.New(float64(cfg.RateLimit.RequestsPerMin)/60.0, cfg.RateLimit.Burst),
|
||||
client: &http.Client{
|
||||
// Transport-level timeout; stream reads rely on the context so a
|
||||
// connected-but-silent upstream is still bounded.
|
||||
Timeout: time.Duration(cfg.Proxy.DefaultTimeoutMs) * time.Millisecond,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// authContext carries the authenticated key + user through a proxy request.
|
||||
type authContext struct {
|
||||
key *store.ApiKey
|
||||
user *store.User
|
||||
}
|
||||
|
||||
// Handle builds a gin handler for a route.
|
||||
func (g *Gateway) Handle(route Route) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
g.proxy(c, route)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) proxy(c *gin.Context, route Route) {
|
||||
start := time.Now()
|
||||
reqID := uuid.NewString()
|
||||
c.Header("X-Request-Id", reqID)
|
||||
|
||||
auth, err := g.authenticate(c)
|
||||
if err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusUnauthorized, "invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "empty request body")
|
||||
return
|
||||
}
|
||||
|
||||
var meta struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &meta); err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if meta.Model == "" {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "missing model field")
|
||||
return
|
||||
}
|
||||
|
||||
// Per-key model whitelist.
|
||||
if len(auth.key.AllowedModels) > 0 && !contains(auth.key.AllowedModels, meta.Model) {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusForbidden,
|
||||
"model not allowed for this API key: "+meta.Model)
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit (user + key).
|
||||
if !g.limiter.Allow(fmt.Sprintf("u:%d", auth.user.ID)) {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
if !g.limiter.Allow(fmt.Sprintf("k:%d", auth.key.ID)) {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusTooManyRequests, "key rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve model.
|
||||
model, err := g.channel.ResolveModel(meta.Model)
|
||||
if err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Channel attempts with failover: on transport/5xx failures the request is
|
||||
// retried against another channel bound to the same model.
|
||||
exclude := map[int64]bool{}
|
||||
attempts := 1 + g.cfg.Proxy.MaxRetries
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
var lastCh *store.Channel
|
||||
formatBlocked := false
|
||||
attempted := false
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
ch, binding, err := g.channel.SelectChannel(model.ID, exclude)
|
||||
if err != nil {
|
||||
// Every bound channel was skipped for format reasons: say so
|
||||
// clearly instead of reporting a generic upstream failure.
|
||||
if formatBlocked && !attempted {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest,
|
||||
"no channel supports the "+string(route.Protocol)+" API format")
|
||||
return
|
||||
}
|
||||
if attempt == 0 {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusServiceUnavailable, "no available channel for model")
|
||||
} else {
|
||||
g.recordError(reqID, auth, model, lastCh, start, "upstream_error")
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "all upstream channels failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
exclude[ch.ID] = true
|
||||
lastCh = ch
|
||||
|
||||
// Skip channels that cannot serve this protocol at all (no native
|
||||
// support and no conversion path).
|
||||
if !channelServesFormat(ch, route) {
|
||||
formatBlocked = true
|
||||
g.log.Debug("channel cannot serve protocol",
|
||||
zap.String("protocol", string(route.Protocol)), zap.Int64("channel_id", ch.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
// Optional balance pre-check with an estimate.
|
||||
if g.cfg.Proxy.BillingExactBalance {
|
||||
estIn := int64(len(body) / 4)
|
||||
estOut := int64(512)
|
||||
if meta.Stream {
|
||||
estOut = int64(g.cfg.Proxy.DefaultMaxTokens)
|
||||
}
|
||||
est, cerr := g.billing.EstimateCost(model.ID, estIn, estOut, 0)
|
||||
if cerr == nil {
|
||||
if berr := g.billing.CheckBalance(auth.user.ID, est); berr != nil {
|
||||
g.recordError(reqID, auth, model, ch, start, "insufficient_balance")
|
||||
g.writeProxyError(c, route.Protocol, http.StatusPaymentRequired, "insufficient balance")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstreamKey, err := g.channel.DecryptKey(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
g.log.Error("decrypt channel key", zap.Error(err), zap.Int64("channel_id", ch.ID))
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "channel key unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
release, err := g.channel.Acquire(ch.ID)
|
||||
if err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusServiceUnavailable, "channel unavailable")
|
||||
return
|
||||
}
|
||||
attempted = true
|
||||
retryable := g.forward(c, route, meta.Model, ch, binding.UpstreamModel, upstreamKey, body, start, reqID, auth, model)
|
||||
release()
|
||||
if !retryable {
|
||||
return
|
||||
}
|
||||
g.log.Warn("upstream failed, retrying on another channel",
|
||||
zap.Int64("model_id", model.ID), zap.Int64("channel_id", ch.ID), zap.Int("attempt", attempt+1))
|
||||
}
|
||||
if formatBlocked && !attempted {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadRequest,
|
||||
"no channel supports the "+string(route.Protocol)+" API format")
|
||||
return
|
||||
}
|
||||
g.recordError(reqID, auth, model, lastCh, start, "upstream_error")
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "all upstream channels failed")
|
||||
}
|
||||
|
||||
// forward decides passthrough vs conversion and calls the upstream.
|
||||
// It returns true when the failure is retryable on another channel.
|
||||
func (g *Gateway) forward(c *gin.Context, route Route, clientModel string,
|
||||
ch *store.Channel, upstreamModel, upstreamKey string, body []byte,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model) bool {
|
||||
|
||||
upstreamBody, converted := g.prepareUpstreamBody(route, ch, body, upstreamModel)
|
||||
if converted {
|
||||
c.Header("x-converted", "true")
|
||||
}
|
||||
|
||||
// Build upstream request bound to the client context so disconnects cancel it.
|
||||
ctx := c.Request.Context()
|
||||
upstreamURL := strings.TrimSuffix(ch.BaseURL, "/") + upstreamPath(route, ch)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(upstreamBody))
|
||||
if err != nil {
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "failed to build upstream request")
|
||||
return false
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+upstreamKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Request-Id", reqID)
|
||||
// Explicitly drop hop-by-hop / auth-ish headers we don't want forwarded.
|
||||
copyProxyHeaders(c, req)
|
||||
|
||||
resp, err := g.client.Do(req)
|
||||
if err != nil {
|
||||
// Client disconnect vs upstream failure.
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
g.recordCanceled(reqID, auth, model, ch, start)
|
||||
return false
|
||||
}
|
||||
g.log.Warn("upstream request failed", zap.Error(err), zap.Int64("channel_id", ch.ID))
|
||||
return true
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
g.log.Warn("upstream returned 5xx", zap.Int("status", resp.StatusCode),
|
||||
zap.Int64("channel_id", ch.ID), zap.String("body", truncateText(string(errBody), 512)))
|
||||
return true
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
g.recordError(reqID, auth, model, ch, start, "upstream_"+strconv.Itoa(resp.StatusCode))
|
||||
g.writeUpstreamError(c, route.Protocol, resp.StatusCode, errBody)
|
||||
return false
|
||||
}
|
||||
|
||||
streaming := bodyStreamFlag(body, route)
|
||||
if streaming {
|
||||
g.streamResponse(c, route, resp, start, reqID, auth, model, ch)
|
||||
} else {
|
||||
g.plainResponse(c, route, resp, start, reqID, auth, model, ch)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareUpstreamBody rewrites the model name, or converts the body when the
|
||||
// channel does not serve the client protocol natively. Returns the payload and
|
||||
// whether any conversion happened.
|
||||
func (g *Gateway) prepareUpstreamBody(route Route, ch *store.Channel, body []byte, upstreamModel string) ([]byte, bool) {
|
||||
if ch.SupportsFormat(string(route.Protocol)) {
|
||||
if upstreamModel == "" || sameModel(body, upstreamModel) {
|
||||
return body, false
|
||||
}
|
||||
rewritten, err := setModelField(body, upstreamModel)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return rewritten, true
|
||||
}
|
||||
converted, err := convertRequest(route, ch.Provider, body, upstreamModel)
|
||||
if err != nil {
|
||||
g.log.Warn("request conversion failed, falling back to passthrough",
|
||||
zap.Error(err), zap.String("route", string(route.Protocol)), zap.String("provider", ch.Provider))
|
||||
return body, false
|
||||
}
|
||||
return converted, true
|
||||
}
|
||||
|
||||
// streamResponse forwards an SSE stream to the client while extracting usage.
|
||||
func (g *Gateway) streamResponse(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
|
||||
if upstreamProvider(route, ch) == "anthropic" {
|
||||
g.streamAnthropic(c, route, resp, start, reqID, auth, model, ch)
|
||||
return
|
||||
}
|
||||
g.streamOpenAI(c, route, resp, start, reqID, auth, model, ch)
|
||||
}
|
||||
|
||||
// plainResponse buffers a non-stream upstream response and returns it.
|
||||
func (g *Gateway) plainResponse(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
g.log.Warn("read upstream body", zap.Error(err))
|
||||
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "failed to read upstream response")
|
||||
return
|
||||
}
|
||||
|
||||
out := raw
|
||||
usageInfo := parseUsageForProtocol(route.Protocol, raw)
|
||||
if !ch.SupportsFormat(string(route.Protocol)) {
|
||||
converted, cerr := convertResponse(route, ch.Provider, raw)
|
||||
if cerr == nil {
|
||||
out = converted
|
||||
usageInfo = parseUsageForProtocol(route.Protocol, out)
|
||||
c.Header("x-converted", "true")
|
||||
} else {
|
||||
g.log.Warn("response conversion failed, forwarding raw",
|
||||
zap.Error(cerr), zap.String("protocol", string(route.Protocol)))
|
||||
}
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", out)
|
||||
g.afterComplete(start, reqID, auth, model, ch, usageInfo, "success", "")
|
||||
}
|
||||
|
||||
// afterComplete performs billing and usage accounting for a finished request.
|
||||
func (g *Gateway) afterComplete(start time.Time, reqID string, auth *authContext,
|
||||
model *store.Model, ch *store.Channel, usageInfo *tokenUsage, status, errCode string) {
|
||||
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
if usageInfo == nil {
|
||||
usageInfo = &tokenUsage{}
|
||||
}
|
||||
cost := billing.CostFromPrices(usageInfo.input, usageInfo.output, usageInfo.cacheRead,
|
||||
billing.PriceSnapshot{
|
||||
InputPrice: model.InputPrice, OutputPrice: model.OutputPrice, CacheReadPrice: model.CacheReadPrice,
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Asynchronous: deduct balance first, then record usage.
|
||||
if cost.IsPositive() {
|
||||
if _, err := g.billing.Deduct(auth.user.ID, cost, "usage", reqID); err != nil {
|
||||
g.log.Warn("deduct balance failed", zap.Error(err),
|
||||
zap.Int64("user_id", auth.user.ID), zap.String("request_id", reqID))
|
||||
}
|
||||
}
|
||||
g.usage.Record(usage.Record{
|
||||
RequestID: reqID,
|
||||
UserID: auth.user.ID,
|
||||
KeyID: auth.key.ID,
|
||||
ChannelID: ch.ID,
|
||||
ModelID: model.ID,
|
||||
ModelName: model.Name,
|
||||
InputTokens: usageInfo.input,
|
||||
OutputTokens: usageInfo.output,
|
||||
CacheReadTokens: usageInfo.cacheRead,
|
||||
CacheCreationTokens: usageInfo.cacheCreation,
|
||||
InputPrice: model.InputPrice,
|
||||
OutputPrice: model.OutputPrice,
|
||||
CacheReadPrice: model.CacheReadPrice,
|
||||
Cost: cost,
|
||||
LatencyMs: latency,
|
||||
Status: status,
|
||||
ErrorCode: errCode,
|
||||
})
|
||||
g.db.Model(&store.ApiKey{}).Where("id = ?", auth.key.ID).
|
||||
Update("last_used_at", time.Now())
|
||||
}()
|
||||
}
|
||||
|
||||
func (g *Gateway) recordError(reqID string, auth *authContext, model *store.Model, ch *store.Channel, start time.Time, errCode string) {
|
||||
g.afterComplete(start, reqID, auth, model, ch, &tokenUsage{}, "error", errCode)
|
||||
}
|
||||
|
||||
func (g *Gateway) recordCanceled(reqID string, auth *authContext, model *store.Model, ch *store.Channel, start time.Time) {
|
||||
g.afterComplete(start, reqID, auth, model, ch, &tokenUsage{}, "canceled", "client_disconnect")
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"openteam/server/internal/pkg/crypto"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// tokenUsage is the normalized usage extracted from any protocol.
|
||||
type tokenUsage struct {
|
||||
input int64
|
||||
output int64
|
||||
cacheRead int64
|
||||
cacheCreation int64
|
||||
}
|
||||
|
||||
// respStreamEvent is the streaming shape of the OpenAI Responses API used for
|
||||
// usage sniffing on passthrough responses streams.
|
||||
type respStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Response *struct {
|
||||
Usage *struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
// authenticate resolves the Bearer API key to a key + user.
|
||||
func (g *Gateway) authenticate(c *gin.Context) (*authContext, error) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
token := ""
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
token = strings.TrimPrefix(auth, "Bearer ")
|
||||
} else if strings.HasPrefix(auth, "sk-") {
|
||||
// Some clients send the raw key without the Bearer scheme.
|
||||
token = auth
|
||||
} else {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
|
||||
var key store.ApiKey
|
||||
if err := g.db.Where("key_hash = ?", crypto.HashSHA256(token)).First(&key).Error; err != nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if key.Status != "active" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if key.ExpiresAt != nil && key.ExpiresAt.Before(now()) {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
var user store.User
|
||||
if err := g.db.First(&user, key.UserID).Error; err != nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
return &authContext{key: &key, user: &user}, nil
|
||||
}
|
||||
|
||||
var errUnauthorized = &unauthorizedError{}
|
||||
|
||||
type unauthorizedError struct{}
|
||||
|
||||
func (*unauthorizedError) Error() string { return "invalid API key" }
|
||||
|
||||
// writeProxyError writes a gateway-generated error in the client's protocol.
|
||||
func (g *Gateway) writeProxyError(c *gin.Context, proto Protocol, status int, message string) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
switch proto {
|
||||
case ProtocolAnthropic:
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"type": "error",
|
||||
"error": map[string]any{"type": statusType(status), "message": message},
|
||||
})
|
||||
default:
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"error": map[string]any{"message": message, "type": "gateway_error", "code": "gateway_error"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeUpstreamError maps an upstream error body to the client protocol.
|
||||
func (g *Gateway) writeUpstreamError(c *gin.Context, proto Protocol, status int, body []byte) {
|
||||
switch proto {
|
||||
case ProtocolAnthropic:
|
||||
// Extract the upstream Claude error if present.
|
||||
var up struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"type": "error",
|
||||
"error": map[string]any{"type": up.Error.Type, "message": up.Error.Message},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"type": "error",
|
||||
"error": map[string]any{"type": statusType(status), "message": upstreamMessage(status, body)},
|
||||
})
|
||||
default:
|
||||
var up struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
Code string `json:"code"`
|
||||
Param string `json:"param"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": up.Error.Message, "type": up.Error.Type, "code": up.Error.Code, "param": up.Error.Param,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": upstreamMessage(status, body), "type": statusType(status), "code": statusType(status),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func statusType(status int) string {
|
||||
switch {
|
||||
case status == 429:
|
||||
return "rate_limit_error"
|
||||
case status >= 500:
|
||||
return "api_error"
|
||||
case status >= 400:
|
||||
return "invalid_request_error"
|
||||
default:
|
||||
return "api_error"
|
||||
}
|
||||
}
|
||||
|
||||
func upstreamMessage(status int, body []byte) string {
|
||||
msg := strings.TrimSpace(string(body))
|
||||
if msg == "" {
|
||||
msg = http.StatusText(status)
|
||||
}
|
||||
return truncateText(msg, 512)
|
||||
}
|
||||
|
||||
func truncateText(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// copyProxyHeaders forwards selected request headers upstream.
|
||||
func copyProxyHeaders(c *gin.Context, req *http.Request) {
|
||||
for _, h := range []string{"OpenAI-Organization", "OpenAI-Beta", "anthropic-version", "anthropic-beta", "X-Stainless-Lang", "X-Stainless-Package-Version"} {
|
||||
if v := c.GetHeader(h); v != "" {
|
||||
req.Header.Set(h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyStreamFlag determines streaming intent from the raw body + route.
|
||||
func bodyStreamFlag(body []byte, route Route) bool {
|
||||
var m struct {
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &m)
|
||||
return m.Stream
|
||||
}
|
||||
|
||||
// setModelField rewrites the "model" key in a JSON object.
|
||||
func setModelField(body []byte, model string) ([]byte, error) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, _ := json.Marshal(model)
|
||||
obj["model"] = m
|
||||
return json.Marshal(obj)
|
||||
}
|
||||
|
||||
// sameModel reports whether the body's model already equals upstreamModel.
|
||||
func sameModel(body []byte, upstreamModel string) bool {
|
||||
var m struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &m)
|
||||
return m.Model == upstreamModel
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, v := range list {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// upstreamPath returns the endpoint to POST to. When the channel serves the
|
||||
// client protocol natively the route's own path is used; otherwise the request
|
||||
// is converted and must hit the channel's conversion-target path.
|
||||
func upstreamPath(route Route, ch *store.Channel) string {
|
||||
if ch.SupportsFormat(string(route.Protocol)) {
|
||||
return route.UpstreamPath
|
||||
}
|
||||
if ch.Provider == "anthropic" {
|
||||
return "/v1/messages"
|
||||
}
|
||||
return "/v1/chat/completions"
|
||||
}
|
||||
|
||||
// upstreamProvider returns the format family ("openai" | "anthropic") the
|
||||
// channel will actually speak for this request: the client's own family on
|
||||
// passthrough, otherwise the channel's conversion target.
|
||||
func upstreamProvider(route Route, ch *store.Channel) string {
|
||||
if ch.SupportsFormat(string(route.Protocol)) {
|
||||
return route.NativeProvider
|
||||
}
|
||||
return ch.Provider
|
||||
}
|
||||
|
||||
// channelServesFormat reports whether the channel can handle the route's
|
||||
// protocol: natively, or via a conversion path that exists. Every protocol can
|
||||
// convert to either Claude or chat completions except one case: Responses-API
|
||||
// requests have no conversion into an openai-family upstream (the
|
||||
// responses->chat response side is unimplemented), so an openai-family channel
|
||||
// that does not declare responses support cannot serve them at all.
|
||||
func channelServesFormat(ch *store.Channel, route Route) bool {
|
||||
if ch.SupportsFormat(string(route.Protocol)) {
|
||||
return true
|
||||
}
|
||||
return !(route.Protocol == ProtocolOpenAIResponses && ch.Provider != "anthropic")
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now() }
|
||||
@@ -0,0 +1,34 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ModelsHandler serves GET /v1/models (OpenAI-style list).
|
||||
type ModelsHandler struct {
|
||||
db *gorm.DB
|
||||
gw *Gateway
|
||||
}
|
||||
|
||||
func NewModelsHandler(db *gorm.DB, gw *Gateway) *ModelsHandler {
|
||||
return &ModelsHandler{db: db, gw: gw}
|
||||
}
|
||||
|
||||
func (h *ModelsHandler) List(c *gin.Context) {
|
||||
models, err := h.gw.channel.ListEnabledModels()
|
||||
if err != nil {
|
||||
c.JSON(502, gin.H{"error": gin.H{"message": "list models failed", "type": "api_error"}})
|
||||
return
|
||||
}
|
||||
data := make([]gin.H, 0, len(models))
|
||||
for _, m := range models {
|
||||
data = append(data, gin.H{
|
||||
"id": m.Name,
|
||||
"object": "model",
|
||||
"created": m.CreatedAt.Unix(),
|
||||
"owned_by": "openteam",
|
||||
})
|
||||
}
|
||||
c.JSON(200, gin.H{"object": "list", "data": data})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package openai
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ChatRequest is a chat completions request. Only the fields the gateway
|
||||
// needs are typed; the rest is preserved via Raw for passthrough.
|
||||
type ChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages json.RawMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TopP *float64 `json:"top_p"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
MaxCompl *int `json:"max_completion_tokens"`
|
||||
Stop json.RawMessage `json:"stop"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
ToolChoice json.RawMessage `json:"tool_choice"`
|
||||
ResponseFmt json.RawMessage `json:"response_format"`
|
||||
StreamOpts json.RawMessage `json:"stream_options"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
|
||||
// ChatMessage is one message in the canonical/chat shape.
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// Tool is a function tool definition.
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionTool `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"`
|
||||
}
|
||||
|
||||
// ChatCompletion is the non-stream response.
|
||||
type ChatCompletion struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChatChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message ChatMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// Usage is the token usage block.
|
||||
type Usage struct {
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// ChatChunk is one streaming chunk.
|
||||
type ChatChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChunkChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ChatChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta ChatDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type ChatDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package responses
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Request is an OpenAI Responses API request. The gateway only needs the
|
||||
// model + stream fields for routing; the full body passes through raw.
|
||||
type Request struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Instructions json.RawMessage `json:"instructions"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens"`
|
||||
PreviousResponseID string `json:"previous_response_id"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
Reasoning json.RawMessage `json:"reasoning"`
|
||||
Text json.RawMessage `json:"text"`
|
||||
OutputFormat json.RawMessage `json:"output_format"`
|
||||
}
|
||||
|
||||
// Response is the non-stream Responses response.
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Output json.RawMessage `json:"output"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
InputTokensDetails UsageDetails `json:"input_tokens_details,omitempty"`
|
||||
OutputTokensDetails UsageDetails `json:"output_tokens_details,omitempty"`
|
||||
}
|
||||
|
||||
type UsageDetails struct {
|
||||
CachedTokens int64 `json:"cached_tokens,omitempty"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"openteam/server/internal/proxy/claude"
|
||||
"openteam/server/internal/proxy/convert"
|
||||
"openteam/server/internal/proxy/openai"
|
||||
"openteam/server/internal/proxy/stream"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// streamOpenAI forwards an SSE stream to an OpenAI-protocol client.
|
||||
func (g *Gateway) streamOpenAI(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
w := c.Writer
|
||||
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
|
||||
done := make(chan struct{})
|
||||
|
||||
if tr == nil {
|
||||
// Passthrough: copy raw frames, sniffing usage from OpenAI chunks
|
||||
// and Responses-API stream events.
|
||||
var usage *tokenUsage
|
||||
onEvent := func(ev stream.SSEEvent) {
|
||||
if ev.Done || ev.Data == "" {
|
||||
return
|
||||
}
|
||||
var chunk openai.ChatChunk
|
||||
if json.Unmarshal([]byte(ev.Data), &chunk) == nil && chunk.Usage != nil {
|
||||
usage = &tokenUsage{
|
||||
input: chunk.Usage.PromptTokens,
|
||||
output: chunk.Usage.CompletionTokens,
|
||||
}
|
||||
return
|
||||
}
|
||||
var resp respStreamEvent
|
||||
if json.Unmarshal([]byte(ev.Data), &resp) == nil && resp.Response != nil && resp.Response.Usage != nil {
|
||||
usage = &tokenUsage{
|
||||
input: resp.Response.Usage.InputTokens,
|
||||
output: resp.Response.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
copyRawSSE(w, resp.Body, onEvent)
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Converted: read upstream events, emit translated frames.
|
||||
var finalUsage *tokenUsage
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
|
||||
frames, ferr := tr.Feed(ev)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
for _, f := range frames {
|
||||
if err := stream.Write(w, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
g.log.Debug("upstream stream read ended", zap.Error(err))
|
||||
}
|
||||
fin, _ := tr.Finish()
|
||||
for _, f := range fin {
|
||||
_ = stream.Write(w, f)
|
||||
}
|
||||
if u := tr.Usage(); u != nil {
|
||||
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
|
||||
}
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
|
||||
}
|
||||
|
||||
// streamAnthropic forwards an SSE stream to a Claude-protocol client.
|
||||
func (g *Gateway) streamAnthropic(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
w := c.Writer
|
||||
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
|
||||
done := make(chan struct{})
|
||||
|
||||
if tr == nil {
|
||||
// Passthrough: copy raw frames, sniffing usage from message_delta.
|
||||
var usage *tokenUsage
|
||||
onEvent := func(ev stream.SSEEvent) {
|
||||
if ev.Done || ev.Data == "" {
|
||||
return
|
||||
}
|
||||
var e claude.StreamEvent
|
||||
if json.Unmarshal([]byte(ev.Data), &e) != nil || e.Type != "message_delta" {
|
||||
return
|
||||
}
|
||||
var u claude.Usage
|
||||
if json.Unmarshal(e.Usage, &u) == nil {
|
||||
usage = &tokenUsage{
|
||||
input: u.InputTokens, output: u.OutputTokens,
|
||||
cacheRead: u.CacheReadInputTokens, cacheCreation: u.CacheCreationInputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
copyRawSSE(w, resp.Body, onEvent)
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Converted: read upstream events, emit translated frames.
|
||||
var finalUsage *tokenUsage
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
|
||||
frames, ferr := tr.Feed(ev)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
for _, f := range frames {
|
||||
if err := stream.Write(w, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
g.log.Debug("upstream stream read ended", zap.Error(err))
|
||||
}
|
||||
fin, _ := tr.Finish()
|
||||
for _, f := range fin {
|
||||
_ = stream.Write(w, f)
|
||||
}
|
||||
if u := tr.Usage(); u != nil {
|
||||
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
|
||||
}
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
|
||||
}
|
||||
|
||||
// copyRawSSE copies an upstream SSE stream verbatim, invoking onEvent for
|
||||
// each data frame (used for passthrough + usage sniffing).
|
||||
func copyRawSSE(w http.ResponseWriter, r io.Reader, onEvent func(stream.SSEEvent)) {
|
||||
br := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := br.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if _, werr := w.Write(line); werr != nil {
|
||||
return
|
||||
}
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
||||
data := bytes.TrimSpace(trimmed[len("data:"):])
|
||||
ev := stream.SSEEvent{
|
||||
Data: string(data),
|
||||
Done: bytes.Equal(data, []byte("[DONE]")),
|
||||
}
|
||||
if ev.Data != "" {
|
||||
onEvent(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// clientProto maps a proxy Protocol to the convert package's protocol type.
|
||||
func clientProto(route Route) convert.ClientProtocol {
|
||||
switch route.Protocol {
|
||||
case ProtocolOpenAIResponses:
|
||||
return convert.ClientOpenAIResponses
|
||||
case ProtocolAnthropic:
|
||||
return convert.ClientAnthropic
|
||||
default:
|
||||
return convert.ClientOpenAIChat
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSEEvent is a single SSE data frame.
|
||||
type SSEEvent struct {
|
||||
Data string // the JSON payload of the `data:` line
|
||||
Done bool // true when the payload is [DONE]
|
||||
}
|
||||
|
||||
// ReadSSE reads SSE frames from r, calling fn for each `data:` line.
|
||||
// It is used both for reading upstream streams and, via a pipe, for writing
|
||||
// converted streams to the client.
|
||||
func ReadSSE(r io.Reader, fn func(SSEEvent) error) error {
|
||||
br := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
line = trimCRLF(line)
|
||||
if !bytes.HasPrefix([]byte(line), []byte("data:")) {
|
||||
continue
|
||||
}
|
||||
data := line[len("data:"):]
|
||||
data = strings.TrimPrefix(data, " ")
|
||||
if len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
ev := SSEEvent{Data: data, Done: bytes.Equal([]byte(data), []byte("[DONE]"))}
|
||||
if err := fn(ev); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write writes an SSE event to w and flushes it.
|
||||
func Write(w http.ResponseWriter, ev SSEEvent) error {
|
||||
if ev.Done {
|
||||
if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := io.WriteString(w, "data: "+ev.Data+"\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteRaw writes a raw SSE frame string (with trailing newlines) and flushes.
|
||||
func WriteRaw(w http.ResponseWriter, frame []byte) error {
|
||||
if _, err := w.Write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimCRLF(s string) string {
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"openteam/server/internal/proxy/claude"
|
||||
"openteam/server/internal/proxy/openai"
|
||||
"openteam/server/internal/proxy/responses"
|
||||
)
|
||||
|
||||
// parseUsageForProtocol extracts token usage from a non-stream response body.
|
||||
func parseUsageForProtocol(proto Protocol, body []byte) *tokenUsage {
|
||||
switch proto {
|
||||
case ProtocolAnthropic:
|
||||
var mr claude.MessageResponse
|
||||
if err := json.Unmarshal(body, &mr); err != nil || mr.Usage == nil {
|
||||
return nil
|
||||
}
|
||||
return &tokenUsage{
|
||||
input: mr.Usage.InputTokens,
|
||||
output: mr.Usage.OutputTokens,
|
||||
cacheRead: mr.Usage.CacheReadInputTokens,
|
||||
cacheCreation: mr.Usage.CacheCreationInputTokens,
|
||||
}
|
||||
case ProtocolOpenAIResponses:
|
||||
var r responses.Response
|
||||
if err := json.Unmarshal(body, &r); err != nil || r.Usage == nil {
|
||||
return nil
|
||||
}
|
||||
return &tokenUsage{
|
||||
input: r.Usage.InputTokens,
|
||||
output: r.Usage.OutputTokens,
|
||||
cacheRead: r.Usage.InputTokensDetails.CachedTokens,
|
||||
}
|
||||
default:
|
||||
var cc openai.ChatCompletion
|
||||
if err := json.Unmarshal(body, &cc); err != nil || cc.Usage == nil {
|
||||
return nil
|
||||
}
|
||||
return &tokenUsage{input: cc.Usage.PromptTokens, output: cc.Usage.CompletionTokens}
|
||||
}
|
||||
}
|
||||
|
||||
// estimateTokensFromText is a coarse fallback used when the upstream omits usage.
|
||||
func estimateTokensFromText(s string) int64 {
|
||||
// ~4 chars per token, per OpenAI's common heuristic.
|
||||
return int64(len(s)/4 + 1)
|
||||
}
|
||||
Reference in New Issue
Block a user