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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+50
View File
@@ -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"`
}
+196
View File
@@ -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
}
+357
View File
@@ -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
}
+161
View File
@@ -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)
}
+416
View File
@@ -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
}
+17
View File
@@ -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)
}
+414
View File
@@ -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")
}
+248
View File
@@ -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() }
+34
View File
@@ -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})
}
+87
View File
@@ -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"`
}
+42
View File
@@ -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"`
}
+201
View File
@@ -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
}
}
+79
View File
@@ -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
}
+48
View File
@@ -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)
}