refactor: move backend files to backend/ directory
Reorganize project structure: - backend/cmd/openteam/ — entry point - backend/internal/ — core packages - backend/middleware/ — HTTP middleware - backend/router/ — route setup - backend/wire/ — dependency injection - backend/pkg/ — shared utilities - backend/go.mod, go.sum — Go module files Updated Makefile to work from backend/ directory. Removed old lowercase makefile.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package convert
|
||||
|
||||
// ChatCompletionRequest represents an OpenAI Chat Completions request
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
N *int `json:"n,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Stop interface{} `json:"stop,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||
LogitBias map[string]int `json:"logit_bias,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
ResponseFormat interface{} `json:"response_format,omitempty"`
|
||||
Seed *int `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
// ChatCompletionResponse represents an OpenAI Chat Completions response
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message Message `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// ChatCompletionStreamChunk represents a streaming chunk
|
||||
type ChatCompletionStreamChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []StreamChoice `json:"choices"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
type StreamChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta StreamDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type StreamDelta struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ChatToMessages converts a Chat Completions request to Anthropic Messages format
|
||||
func ChatToMessages(req *ChatCompletionRequest) (*MessagesRequest, error) {
|
||||
msgs := make([]Message, 0, len(req.Messages))
|
||||
var systemParts []ContentPart
|
||||
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
// Extract system message content
|
||||
switch v := m.Content.(type) {
|
||||
case string:
|
||||
systemParts = append(systemParts, ContentPart{
|
||||
Type: "text",
|
||||
Text: v,
|
||||
})
|
||||
case []interface{}:
|
||||
for _, part := range v {
|
||||
if p, ok := part.(map[string]interface{}); ok {
|
||||
if t, ok := p["type"].(string); ok && t == "text" {
|
||||
if text, ok := p["text"].(string); ok {
|
||||
systemParts = append(systemParts, ContentPart{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
|
||||
out := &MessagesRequest{
|
||||
Model: req.Model,
|
||||
Messages: msgs,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
|
||||
if len(systemParts) > 0 {
|
||||
out.System = systemParts
|
||||
}
|
||||
|
||||
if req.MaxTokens != nil {
|
||||
out.MaxTokens = *req.MaxTokens
|
||||
} else {
|
||||
defaultMax := 4096
|
||||
out.MaxTokens = defaultMax
|
||||
}
|
||||
|
||||
if req.Temperature != nil {
|
||||
out.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
out.TopP = req.TopP
|
||||
}
|
||||
if req.Tools != nil {
|
||||
out.Tools = req.Tools
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MessagesToChat converts an Anthropic Messages response to Chat Completions format
|
||||
func MessagesToChat(resp *MessagesResponse) (*ChatCompletionResponse, error) {
|
||||
choices := make([]Choice, 0)
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
choices = append(choices, Choice{
|
||||
Index: len(choices),
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
Content: block.Text,
|
||||
},
|
||||
FinishReason: mapStopReason(resp.StopReason),
|
||||
})
|
||||
case "tool_use":
|
||||
toolCall := ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: block.Name,
|
||||
Arguments: toJSON(block.Input),
|
||||
},
|
||||
}
|
||||
if len(choices) == 0 {
|
||||
choices = append(choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{toolCall},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
})
|
||||
} else {
|
||||
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
|
||||
choices[0].FinishReason = "tool_calls"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(choices) == 0 {
|
||||
choices = append(choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
},
|
||||
FinishReason: "stop",
|
||||
})
|
||||
}
|
||||
|
||||
return &ChatCompletionResponse{
|
||||
ID: resp.ID,
|
||||
Object: "chat.completion",
|
||||
Model: resp.Model,
|
||||
Choices: choices,
|
||||
Usage: &Usage{
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MessagesStreamToChatStream converts Anthropic streaming chunks to Chat Completions format
|
||||
func MessagesStreamToChatStream(anthropicEvents []AnthropicStreamEvent, model string) []ChatCompletionStreamChunk {
|
||||
var chunks []ChatCompletionStreamChunk
|
||||
id := fmt.Sprintf("chatcmpl-%d", len(anthropicEvents))
|
||||
|
||||
for _, event := range anthropicEvents {
|
||||
switch event.Type {
|
||||
case "message_start":
|
||||
// Initial chunk with role
|
||||
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
Delta: StreamDelta{
|
||||
Role: "assistant",
|
||||
},
|
||||
}},
|
||||
})
|
||||
case "content_block_delta":
|
||||
if event.Delta != nil && event.Delta.Text != "" {
|
||||
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
Delta: StreamDelta{
|
||||
Content: event.Delta.Text,
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
case "message_delta":
|
||||
finishReason := "stop"
|
||||
if event.Delta != nil && event.Delta.StopReason != "" {
|
||||
finishReason = mapStopReason(event.Delta.StopReason)
|
||||
}
|
||||
chunk := ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
FinishReason: &finishReason,
|
||||
}},
|
||||
}
|
||||
if event.Usage != nil {
|
||||
chunk.Usage = event.Usage
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func mapStopReason(reason string) string {
|
||||
switch reason {
|
||||
case "end_turn", "stop_sequence":
|
||||
return "stop"
|
||||
case "tool_use":
|
||||
return "tool_calls"
|
||||
case "max_tokens":
|
||||
return "length"
|
||||
default:
|
||||
return "stop"
|
||||
}
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ChatToResponses converts a Chat Completions request to Responses API format
|
||||
func ChatToResponses(req *ChatCompletionRequest) (*ResponsesRequest, error) {
|
||||
var inputItems []InputItem
|
||||
var instructions string
|
||||
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
if instructions != "" {
|
||||
instructions += "\n\n"
|
||||
}
|
||||
instructions += s
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
item := InputItem{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
}
|
||||
inputItems = append(inputItems, item)
|
||||
}
|
||||
|
||||
out := &ResponsesRequest{
|
||||
Model: req.Model,
|
||||
Input: inputItems,
|
||||
Instructions: instructions,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
|
||||
if req.MaxTokens != nil {
|
||||
out.MaxOutputTokens = req.MaxTokens
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
out.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
out.TopP = req.TopP
|
||||
}
|
||||
if req.Tools != nil {
|
||||
out.Tools = req.Tools
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResponsesToChat converts a Responses API response to Chat Completions format
|
||||
func ResponsesToChat(resp *ResponsesResponse) (*ChatCompletionResponse, error) {
|
||||
choices := make([]Choice, 0)
|
||||
|
||||
for _, output := range resp.Output {
|
||||
switch output.Type {
|
||||
case "message":
|
||||
for _, content := range output.Content {
|
||||
switch content.Type {
|
||||
case "output_text":
|
||||
choices = append(choices, Choice{
|
||||
Index: len(choices),
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
Content: content.Text,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
})
|
||||
case "function_call":
|
||||
toolCall := ToolCall{
|
||||
ID: content.ID,
|
||||
Type: "function",
|
||||
Function: FunctionCall{
|
||||
Name: content.Name,
|
||||
Arguments: toJSON(content.Input),
|
||||
},
|
||||
}
|
||||
if len(choices) == 0 {
|
||||
choices = append(choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{toolCall},
|
||||
},
|
||||
FinishReason: "tool_calls",
|
||||
})
|
||||
} else {
|
||||
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
|
||||
choices[0].FinishReason = "tool_calls"
|
||||
}
|
||||
}
|
||||
}
|
||||
case "function_call_output":
|
||||
// This would be in a user message context
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(choices) == 0 {
|
||||
choices = append(choices, Choice{
|
||||
Index: 0,
|
||||
Message: Message{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
},
|
||||
FinishReason: "stop",
|
||||
})
|
||||
}
|
||||
|
||||
return &ChatCompletionResponse{
|
||||
ID: resp.ID,
|
||||
Object: "chat.completion",
|
||||
Model: resp.Model,
|
||||
Choices: choices,
|
||||
Usage: &Usage{
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResponsesStreamToChatStream converts Responses API streaming to Chat Completions format
|
||||
func ResponsesStreamToChatStream(events []ResponsesStreamEvent, model string) []ChatCompletionStreamChunk {
|
||||
var chunks []ChatCompletionStreamChunk
|
||||
id := fmt.Sprintf("chatcmpl-%d", len(events))
|
||||
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "response.created":
|
||||
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
Delta: StreamDelta{
|
||||
Role: "assistant",
|
||||
},
|
||||
}},
|
||||
})
|
||||
case "response.output_item.added":
|
||||
if event.Item != nil && event.Item.Type == "message" {
|
||||
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
Delta: StreamDelta{
|
||||
Role: "assistant",
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
case "response.content_part.delta":
|
||||
if event.Delta != "" {
|
||||
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
Delta: StreamDelta{
|
||||
Content: event.Delta,
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
case "response.completed":
|
||||
finishReason := "stop"
|
||||
chunk := ChatCompletionStreamChunk{
|
||||
ID: id,
|
||||
Object: "chat.completion.chunk",
|
||||
Model: model,
|
||||
Choices: []StreamChoice{{
|
||||
Index: 0,
|
||||
FinishReason: &finishReason,
|
||||
}},
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// MessagesToResponses converts an Anthropic Messages request to Responses API format
|
||||
func MessagesToResponses(req *MessagesRequest) (*ResponsesRequest, error) {
|
||||
var inputItems []InputItem
|
||||
var instructions string
|
||||
|
||||
// Handle system message
|
||||
if req.System != nil {
|
||||
switch v := req.System.(type) {
|
||||
case string:
|
||||
instructions = v
|
||||
case []ContentPart:
|
||||
for _, p := range v {
|
||||
if p.Type == "text" {
|
||||
if instructions != "" {
|
||||
instructions += "\n\n"
|
||||
}
|
||||
instructions += p.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range req.Messages {
|
||||
item := InputItem{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
}
|
||||
inputItems = append(inputItems, item)
|
||||
}
|
||||
|
||||
out := &ResponsesRequest{
|
||||
Model: req.Model,
|
||||
Input: inputItems,
|
||||
Instructions: instructions,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
|
||||
out.MaxOutputTokens = &req.MaxTokens
|
||||
|
||||
if req.Temperature != nil {
|
||||
out.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
out.TopP = req.TopP
|
||||
}
|
||||
if req.Tools != nil {
|
||||
out.Tools = req.Tools
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResponsesToMessages converts a Responses API response to Anthropic Messages format
|
||||
func ResponsesToMessages(resp *ResponsesResponse) (*MessagesResponse, error) {
|
||||
var content []ContentBlock
|
||||
|
||||
for _, output := range resp.Output {
|
||||
switch output.Type {
|
||||
case "message":
|
||||
for _, c := range output.Content {
|
||||
switch c.Type {
|
||||
case "output_text":
|
||||
content = append(content, ContentBlock{
|
||||
Type: "text",
|
||||
Text: c.Text,
|
||||
})
|
||||
case "function_call":
|
||||
content = append(content, ContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stopReason string
|
||||
if len(content) > 0 {
|
||||
last := content[len(content)-1]
|
||||
if last.Type == "tool_use" {
|
||||
stopReason = "tool_use"
|
||||
} else {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
} else {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
|
||||
return &MessagesResponse{
|
||||
ID: resp.ID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
Model: resp.Model,
|
||||
StopReason: stopReason,
|
||||
Usage: resp.Usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toJSON is a helper to convert a value to JSON string
|
||||
func toJSONStr(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatToMessages(t *testing.T) {
|
||||
maxTokens := 1024
|
||||
temp := 0.7
|
||||
|
||||
req := &ChatCompletionRequest{
|
||||
Model: "claude-3-sonnet-20240229",
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "Hello!"},
|
||||
},
|
||||
MaxTokens: &maxTokens,
|
||||
Temperature: &temp,
|
||||
}
|
||||
|
||||
result, err := ChatToMessages(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatToMessages() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Model != "claude-3-sonnet-20240229" {
|
||||
t.Errorf("Model = %q, want %q", result.Model, "claude-3-sonnet-20240229")
|
||||
}
|
||||
|
||||
if len(result.Messages) != 1 {
|
||||
t.Errorf("Messages length = %d, want 1", len(result.Messages))
|
||||
}
|
||||
|
||||
if result.Messages[0].Role != "user" {
|
||||
t.Errorf("Messages[0].Role = %q, want %q", result.Messages[0].Role, "user")
|
||||
}
|
||||
|
||||
if result.System == nil {
|
||||
t.Error("System is nil, want non-nil")
|
||||
}
|
||||
|
||||
if result.MaxTokens != 1024 {
|
||||
t.Errorf("MaxTokens = %d, want 1024", result.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToChat(t *testing.T) {
|
||||
resp := &MessagesResponse{
|
||||
ID: "msg-123",
|
||||
Model: "claude-3-sonnet-20240229",
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: "Hello! How can I help?"},
|
||||
},
|
||||
StopReason: "end_turn",
|
||||
Usage: Usage{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 20,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := MessagesToChat(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesToChat() error = %v", err)
|
||||
}
|
||||
|
||||
if result.ID != "msg-123" {
|
||||
t.Errorf("ID = %q, want %q", result.ID, "msg-123")
|
||||
}
|
||||
|
||||
if result.Object != "chat.completion" {
|
||||
t.Errorf("Object = %q, want %q", result.Object, "chat.completion")
|
||||
}
|
||||
|
||||
if len(result.Choices) != 1 {
|
||||
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||
return
|
||||
}
|
||||
|
||||
if result.Choices[0].Message.Role != "assistant" {
|
||||
t.Errorf("Choices[0].Message.Role = %q, want %q", result.Choices[0].Message.Role, "assistant")
|
||||
}
|
||||
|
||||
if result.Choices[0].Message.Content != "Hello! How can I help?" {
|
||||
t.Errorf("Choices[0].Message.Content = %q, want %q", result.Choices[0].Message.Content, "Hello! How can I help?")
|
||||
}
|
||||
|
||||
if result.Choices[0].FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "stop")
|
||||
}
|
||||
|
||||
if result.Usage.TotalTokens != 30 {
|
||||
t.Errorf("Usage.TotalTokens = %d, want 30", result.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatToResponses(t *testing.T) {
|
||||
maxTokens := 2048
|
||||
|
||||
req := &ChatCompletionRequest{
|
||||
Model: "gpt-4o",
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: "You are a helpful assistant."},
|
||||
{Role: "user", Content: "What is 2+2?"},
|
||||
},
|
||||
MaxTokens: &maxTokens,
|
||||
}
|
||||
|
||||
result, err := ChatToResponses(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatToResponses() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Model != "gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", result.Model, "gpt-4o")
|
||||
}
|
||||
|
||||
if len(result.Input) != 1 {
|
||||
t.Errorf("Input length = %d, want 1", len(result.Input))
|
||||
}
|
||||
|
||||
if result.Input[0].Role != "user" {
|
||||
t.Errorf("Input[0].Role = %q, want %q", result.Input[0].Role, "user")
|
||||
}
|
||||
|
||||
if result.Instructions != "You are a helpful assistant." {
|
||||
t.Errorf("Instructions = %q, want %q", result.Instructions, "You are a helpful assistant.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToChat(t *testing.T) {
|
||||
resp := &ResponsesResponse{
|
||||
ID: "resp-123",
|
||||
Model: "gpt-4o",
|
||||
Status: "completed",
|
||||
Output: []OutputItem{
|
||||
{
|
||||
Type: "message",
|
||||
Content: []OutputContent{
|
||||
{Type: "output_text", Text: "2+2 equals 4."},
|
||||
},
|
||||
},
|
||||
},
|
||||
Usage: Usage{
|
||||
PromptTokens: 15,
|
||||
CompletionTokens: 10,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ResponsesToChat(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ResponsesToChat() error = %v", err)
|
||||
}
|
||||
|
||||
if result.ID != "resp-123" {
|
||||
t.Errorf("ID = %q, want %q", result.ID, "resp-123")
|
||||
}
|
||||
|
||||
if len(result.Choices) != 1 {
|
||||
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||
return
|
||||
}
|
||||
|
||||
if result.Choices[0].Message.Content != "2+2 equals 4." {
|
||||
t.Errorf("Content = %q, want %q", result.Choices[0].Message.Content, "2+2 equals 4.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapStopReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"end_turn", "stop"},
|
||||
{"stop_sequence", "stop"},
|
||||
{"tool_use", "tool_calls"},
|
||||
{"max_tokens", "length"},
|
||||
{"unknown", "stop"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := mapStopReason(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("mapStopReason(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToChatToolUse(t *testing.T) {
|
||||
resp := &MessagesResponse{
|
||||
ID: "msg-456",
|
||||
Model: "claude-3-sonnet-20240229",
|
||||
Content: []ContentBlock{
|
||||
{Type: "text", Text: "Let me search for that."},
|
||||
{Type: "tool_use", ID: "toolu-123", Name: "web_search"},
|
||||
},
|
||||
StopReason: "tool_use",
|
||||
Usage: Usage{
|
||||
PromptTokens: 20,
|
||||
CompletionTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := MessagesToChat(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("MessagesToChat() error = %v", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) != 1 {
|
||||
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||
return
|
||||
}
|
||||
|
||||
if result.Choices[0].FinishReason != "tool_calls" {
|
||||
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "tool_calls")
|
||||
}
|
||||
|
||||
if len(result.Choices[0].Message.ToolCalls) != 1 {
|
||||
t.Errorf("ToolCalls length = %d, want 1", len(result.Choices[0].Message.ToolCalls))
|
||||
return
|
||||
}
|
||||
|
||||
if result.Choices[0].Message.ToolCalls[0].ID != "toolu-123" {
|
||||
t.Errorf("ToolCall ID = %q, want %q", result.Choices[0].Message.ToolCalls[0].ID, "toolu-123")
|
||||
}
|
||||
|
||||
if result.Choices[0].Message.ToolCalls[0].Function.Name != "web_search" {
|
||||
t.Errorf("Function.Name = %q, want %q", result.Choices[0].Message.ToolCalls[0].Function.Name, "web_search")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package convert
|
||||
|
||||
// MessagesRequest represents an Anthropic Messages API request
|
||||
type MessagesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System interface{} `json:"system,omitempty"` // string or []ContentPart
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
Metadata interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// MessagesResponse represents an Anthropic Messages API response
|
||||
type MessagesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []ContentBlock `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence string `json:"stop_sequence,omitempty"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// AnthropicStreamEvent represents an Anthropic streaming event
|
||||
type AnthropicStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Delta *Delta `json:"delta,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package convert
|
||||
|
||||
// ResponsesRequest represents an OpenAI Responses API request
|
||||
type ResponsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []InputItem `json:"input"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Metadata interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// InputItem represents a single input item
|
||||
type InputItem struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesResponse represents an OpenAI Responses API response
|
||||
type ResponsesResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
Output []OutputItem `json:"output"`
|
||||
Usage Usage `json:"usage"`
|
||||
Error interface{} `json:"error,omitempty"`
|
||||
Incomplete *Incomplete `json:"incomplete,omitempty"`
|
||||
}
|
||||
|
||||
type OutputItem struct {
|
||||
Type string `json:"type"`
|
||||
Content []OutputContent `json:"content,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type OutputContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
type Incomplete struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ResponsesStreamEvent represents a Responses API streaming event
|
||||
type ResponsesStreamEvent struct {
|
||||
Type string `json:"type"`
|
||||
Item *OutputItem `json:"item,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSEWriter writes Server-Sent Events
|
||||
type SSEWriter struct {
|
||||
writer io.Writer
|
||||
flusher http.Flusher
|
||||
}
|
||||
|
||||
// NewSSEWriter creates a new SSE writer
|
||||
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
return &SSEWriter{
|
||||
writer: w,
|
||||
flusher: flusher,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteEvent writes a single SSE event
|
||||
func (w *SSEWriter) WriteEvent(event string, data interface{}) error {
|
||||
var dataStr string
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
dataStr = v
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataStr = string(b)
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(w.writer, "event: %s\ndata: %s\n\n", event, dataStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w.flusher != nil {
|
||||
w.flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteChunk writes a streaming chunk in SSE format
|
||||
func (w *SSEWriter) WriteChunk(chunk interface{}) error {
|
||||
b, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(w.writer, "data: %s\n\n", string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w.flusher != nil {
|
||||
w.flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteDone writes the [DONE] marker
|
||||
func (w *SSEWriter) WriteDone() error {
|
||||
_, err := fmt.Fprintf(w.writer, "data: [DONE]\n\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if w.flusher != nil {
|
||||
w.flusher.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSEParser parses Server-Sent Events from a reader
|
||||
type SSEParser struct {
|
||||
reader *bufio.Reader
|
||||
}
|
||||
|
||||
// NewSSEParser creates a new SSE parser
|
||||
func NewSSEParser(r io.Reader) *SSEParser {
|
||||
return &SSEParser{
|
||||
reader: bufio.NewReader(r),
|
||||
}
|
||||
}
|
||||
|
||||
// SSEEvent represents a parsed SSE event
|
||||
type SSEEvent struct {
|
||||
Event string
|
||||
Data string
|
||||
}
|
||||
|
||||
// ReadEvent reads the next SSE event
|
||||
func (p *SSEParser) ReadEvent() (*SSEEvent, error) {
|
||||
event := &SSEEvent{}
|
||||
|
||||
for {
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
|
||||
if line == "" {
|
||||
// Empty line means end of event
|
||||
if event.Data != "" || event.Event != "" {
|
||||
return event, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
event.Event = strings.TrimSpace(line[6:])
|
||||
} else if strings.HasPrefix(line, "data:") {
|
||||
data := strings.TrimSpace(line[5:])
|
||||
if event.Data != "" {
|
||||
event.Data += "\n" + data
|
||||
} else {
|
||||
event.Data = data
|
||||
}
|
||||
}
|
||||
// Ignore comments (lines starting with :) and unknown fields
|
||||
}
|
||||
}
|
||||
|
||||
// ParseChatStreamChunk parses an OpenAI Chat Completions streaming chunk
|
||||
func ParseChatStreamChunk(data string) (*ChatCompletionStreamChunk, error) {
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
var chunk ChatCompletionStreamChunk
|
||||
err := json.Unmarshal([]byte(data), &chunk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &chunk, nil
|
||||
}
|
||||
|
||||
// ParseMessagesStreamEvent parses an Anthropic Messages streaming event
|
||||
func ParseMessagesStreamEvent(data string) (*AnthropicStreamEvent, error) {
|
||||
var event AnthropicStreamEvent
|
||||
err := json.Unmarshal([]byte(data), &event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
// ParseResponsesStreamChunk parses an OpenAI Responses API streaming chunk
|
||||
func ParseResponsesStreamChunk(data string) (*ResponsesStreamEvent, error) {
|
||||
if data == "[DONE]" {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
var event ResponsesStreamEvent
|
||||
err := json.Unmarshal([]byte(data), &event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package convert
|
||||
|
||||
// Common types shared across all protocols
|
||||
|
||||
// Message represents a unified message format
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"` // string or []ContentPart
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
// ContentPart represents a part of a multi-part message content
|
||||
type ContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||
Source *ImageSource `json:"source,omitempty"`
|
||||
ToolUse *ToolUse `json:"tool_use,omitempty"`
|
||||
ToolResult *ToolResult `json:"tool_result,omitempty"`
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolUse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Input interface{} `json:"input"`
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Tool definition
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolDefinition `json:"function,omitempty"`
|
||||
Name string `json:"name,omitempty"` // Anthropic style
|
||||
Input interface{} `json:"input_schema,omitempty"` // Anthropic style
|
||||
}
|
||||
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Parameters interface{} `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// StreamEvent represents a unified streaming event
|
||||
type StreamEvent struct {
|
||||
Type string `json:"type"` // "message_start", "content_block_start", "content_block_delta", "message_delta", "message_stop"
|
||||
Delta *Delta `json:"delta,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type Delta struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
ContentBlock *ContentBlock `json:"content_block,omitempty"`
|
||||
}
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// Usage represents token usage
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
CacheReadTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"opencatd-open/internal/channel"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/proxy/convert"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/pkg/config"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
wg *sync.WaitGroup
|
||||
httpClient *http.Client
|
||||
|
||||
userDAO *dao.UserDAO
|
||||
apiKeyDAO *dao.ApiKeyDAO
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
channelSvc *channel.Service
|
||||
}
|
||||
|
||||
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
if os.Getenv("LOCAL_PROXY") != "" {
|
||||
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||
if err == nil {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyUrl),
|
||||
}
|
||||
client.Transport = tr
|
||||
}
|
||||
}
|
||||
|
||||
return &Gateway{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
wg: wg,
|
||||
httpClient: client,
|
||||
userDAO: userDAO,
|
||||
apiKeyDAO: apiKeyDAO,
|
||||
usageDAO: usageDAO,
|
||||
dailyDAO: dailyDAO,
|
||||
channelSvc: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||
g.channelSvc = svc
|
||||
}
|
||||
|
||||
// Request represents a parsed incoming request
|
||||
type Request struct {
|
||||
Model string
|
||||
Stream bool
|
||||
Protocol string // "chat", "messages", "responses"
|
||||
Body []byte
|
||||
APIKey *store.APIKey
|
||||
UserID uint64
|
||||
}
|
||||
|
||||
// ParseRequest parses the incoming request and extracts key fields
|
||||
func (g *Gateway) ParseRequest(c *gin.Context, protocol string) (*Request, error) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read body: %w", err)
|
||||
}
|
||||
|
||||
apiKey, _ := c.Get("api_key")
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
req := &Request{
|
||||
Protocol: protocol,
|
||||
Body: body,
|
||||
UserID: userID.(uint64),
|
||||
}
|
||||
|
||||
if ak, ok := apiKey.(*store.APIKey); ok {
|
||||
req.APIKey = ak
|
||||
}
|
||||
|
||||
// Parse model and stream based on protocol
|
||||
switch protocol {
|
||||
case "chat":
|
||||
var parsed convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid chat request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
case "messages":
|
||||
var parsed convert.MessagesRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid messages request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
case "responses":
|
||||
var parsed convert.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid responses request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Dispatch routes the request to the appropriate upstream
|
||||
func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
if g.channelSvc == nil {
|
||||
g.writeError(c, http.StatusBadGateway, "channel service not available")
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := g.channelSvc.SelectChannel(g.ctx, req.Model)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := g.channelSvc.GetAPIKey(ch)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to decrypt API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target format and convert if needed
|
||||
targetFormat := req.Protocol
|
||||
if len(ch.FormatsEffective()) > 0 {
|
||||
// Prefer the channel's native format
|
||||
for _, f := range ch.FormatsEffective() {
|
||||
if f == req.Protocol {
|
||||
targetFormat = f
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
upstreamPath := g.getUpstreamPath(req.Protocol)
|
||||
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath)
|
||||
|
||||
// Convert request if needed
|
||||
var requestBody []byte
|
||||
if targetFormat != req.Protocol {
|
||||
requestBody, err = g.convertRequest(req.Body, req.Protocol, targetFormat)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requestBody = req.Body
|
||||
}
|
||||
|
||||
// Create upstream request
|
||||
httpReq, err := http.NewRequestWithContext(g.ctx, "POST", upstreamURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to create request")
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers
|
||||
g.setHeaders(httpReq, ch, apiKey, targetFormat)
|
||||
|
||||
// Execute request
|
||||
start := time.Now()
|
||||
resp, err := g.httpClient.Do(httpReq)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
g.channelSvc.RecordFailure(ch.ID)
|
||||
g.writeError(c, http.StatusBadGateway, fmt.Sprintf("upstream error: %v (latency: %v)", err, latency))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Record success
|
||||
g.channelSvc.RecordSuccess(ch.ID)
|
||||
|
||||
// Handle response
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("Upstream error: status=%d body=%s", resp.StatusCode, string(body))
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream or buffer response
|
||||
if req.Stream {
|
||||
g.streamResponse(c, resp, req.Protocol, ch)
|
||||
} else {
|
||||
g.bufferResponse(c, resp, req.Protocol, ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) getUpstreamPath(protocol string) string {
|
||||
switch protocol {
|
||||
case "chat":
|
||||
return "/chat/completions"
|
||||
case "messages":
|
||||
return "/messages"
|
||||
case "responses":
|
||||
return "/responses"
|
||||
default:
|
||||
return "/chat/completions"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string, format string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
switch ch.Provider {
|
||||
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
case store.ChannelProviderAnthropic:
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) convertRequest(body []byte, from, to string) ([]byte, error) {
|
||||
switch {
|
||||
case from == "chat" && to == "messages":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgReq, err := convert.ChatToMessages(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(msgReq)
|
||||
|
||||
case from == "chat" && to == "responses":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respReq, err := convert.ChatToResponses(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(respReq)
|
||||
|
||||
case from == "messages" && to == "chat":
|
||||
var req convert.MessagesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Messages -> Chat: we need to construct a ChatCompletionRequest
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
chatReq.Messages = append(chatReq.Messages, m)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
chatReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
chatReq.TopP = req.TopP
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
case from == "responses" && to == "chat":
|
||||
var req convert.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, item := range req.Input {
|
||||
chatReq.Messages = append(chatReq.Messages, convert.Message{
|
||||
Role: item.Role,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
writer := convert.NewSSEWriter(c.Writer)
|
||||
parser := convert.NewSSEParser(resp.Body)
|
||||
|
||||
for {
|
||||
event, err := parser.ReadEvent()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Printf("Stream parse error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if event.Event == "error" {
|
||||
log.Printf("Upstream stream error: %s", event.Data)
|
||||
break
|
||||
}
|
||||
|
||||
// Write raw SSE event based on protocol
|
||||
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteDone()
|
||||
}
|
||||
|
||||
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
}
|
||||
|
||||
func (g *Gateway) writeError(c *gin.Context, status int, message string) {
|
||||
protocol := c.GetHeader("X-Protocol")
|
||||
if protocol == "" {
|
||||
protocol = "chat"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(c.GetHeader("Accept"), "text/event-stream"):
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Status(status)
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":{\"message\":\"%s\"}}\n\n", message)
|
||||
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||
case protocol == "messages":
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "api_error",
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
default:
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"message": message,
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HandleChat handles POST /v1/chat/completions
|
||||
func (g *Gateway) HandleChat(c *gin.Context) {
|
||||
req, err := g.ParseRequest(c, "chat")
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g.Dispatch(c, req)
|
||||
}
|
||||
|
||||
// HandleMessages handles POST /v1/messages
|
||||
func (g *Gateway) HandleMessages(c *gin.Context) {
|
||||
req, err := g.ParseRequest(c, "messages")
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g.Dispatch(c, req)
|
||||
}
|
||||
|
||||
// HandleResponses handles POST /v1/responses
|
||||
func (g *Gateway) HandleResponses(c *gin.Context) {
|
||||
req, err := g.ParseRequest(c, "responses")
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g.Dispatch(c, req)
|
||||
}
|
||||
|
||||
// HandleModels handles GET /v1/models
|
||||
func (g *Gateway) HandleModels(c *gin.Context) {
|
||||
// TODO: Return list of available models based on enabled channels
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": []interface{}{},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user