路由与故障转移(参考 openteam 语义) - channel.Candidates:绑定模型优先(携带 upstream_model 映射), 未绑定模型回退到权重最低的健康备用渠道;新增 Pick 加权随机与 FilterHealthy 内存健康过滤 - gateway.Dispatch:遍历候选渠道,可重试失败(连接错误/429/5xx)自动故障转移, 4xx 透传;不再使用单一 SelectChannel - 修复 gorm default 标签把渠道 weight=0 静默改写为 1 的问题(去掉 default, 权重 0 语义 = 不参与加权选择,仅作备用承接 unbound 流量) - RecordFailure 连续 2 次进入 degraded 快速熔断,健康检查成功或冷却过期后复位 网关功能补全 - /v1/models 返回 DB 中启用的模型列表(替换 TODO 存根) - 请求级 request_id 生成与用量记录接入:流式 SSE 逐块累计 usage、 非流式从响应提取,按模型定价计算成本后经 usage.Recorder 异步落库 - 流式结束检测:chat 的 [DONE]、messages 的 message_stop、responses 的 response.completed,避免 keep-alive 上游发完不关连接导致读阻塞到超时 - ResponsesRequest.input 兼容字符串与条目数组两种客户端写法 测试 - 修复 convert_test 对新 input 形态的断言 - 网关 e2e(/tmp/test_gateway.py + mock upstream)72/72 全部通过,连续 3 次稳定
299 lines
6.5 KiB
Go
299 lines
6.5 KiB
Go
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: marshalInputItems(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: marshalInputItems(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)
|
|
}
|