Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- **模型清单展示改进** - 共有/协议专属模型少于 10 个时直接展开列出,其余默认两行、仅实际溢出时显示展开
- **火山套餐 Key 保活 per-key 路由与共享探针** - `config.BaseURLsForKey` 让已绑定端点的 Key 只在自己端点探测,不参与渠道级 BaseURL 笛卡尔积;新增 `internal/upstreamprobe` 共享火山 Agent/Coding Plan 数据面探针(autopilot 验证与 healthcheck 保活共用,避免请求特征漂移);`L1Response.RealCallVerified` 标记真实调用,火山 L1 成功后同周期跳过等价 L2 避免重复消耗额度;L2 探针副本覆盖绑定 BaseURL,recordFailure 归因到 Key 实际绑定端点
- **渠道级实测上下文上限自学习** - 模型注册表登记的是模型公开窗口,个别渠道对某个模型的实际窗口更短(中转商自行截断、上游按套餐限制),长上下文请求会反复吃 400 `context_too_large`。新增按 渠道-Key-模型 维度记忆实测上限的数值型能力覆盖(`config.ContextLimitState`,复用 `ChannelCompatCache` 键空间/24h TTL/落盘,但独立于布尔型 `CompatTrait` 存储):`ContextLimitFromError` 从 400/422 报错识别超限信号,上游明确声明窗口值时直接采信,仅表示「太长」时按被拒请求量的 7/8 反推保守上界,并排除 `max_tokens`/请求体过大/图片尺寸/配额等相邻错误避免误判;合成规则「宁小勿大」多次学习取最小值,只在遇到更严格证据时收紧,放宽只能靠 TTL 过期重学;`SmartRouter.buildChannelEntry` 取 `min(注册表窗口, 实测上限)` 让上下文硬约束按真实容量判断,路由发生在选定 Key 之前故取该渠道-模型在所有已知 Key 上的最小值。上下文上限按自身 `LearnedAt` 独立计算 TTL(不共用 `entry.DetectedAt`,否则 trait 命中刷新会让实测上限被无限续期);无记忆时 fail-open,反推上界低于 4096 不采信。兼容性记忆单例上移为 `config.SharedChannelCompatCache`,供写入方 handlers 与读取方 autopilot 共享
- **OpenRouter Responses 思考内容标准化** - OpenRouter 的 Responses API 实现与 OpenAI 规范存在系统性偏差:非流式 reasoning 条目把思考文本放在非标准 `content[]`(part 类型 `reasoning_text`)而规范位置 `summary[]` 留空;流式思考增量用非标准事件 `response.reasoning_text.delta/.done`、content_part 事件携带 `reasoning_text` part,而非规范的 `response.reasoning_summary_text.*` / `response.reasoning_summary_part.*` 与 `summary_text` part(分片序号也复用了 `content_index` 而非 `summary_index`)。按 OpenAI 规范实现的客户端(Codex CLI 等)只读 summary/summary 系事件,导致经 OpenRouter 中转的推理模型思考过程不可见。新增 `converters.NormalizeOpenRouterReasoningEvent/NormalizeOpenRouterReasoningResponseBody/NormalizeOpenRouterReasoningSSELine` 把非标准形态归一化为规范形态,全部按数据形状触发:仅事件名精确命中、或 summary 为空且 content 全为 reasoning_text 时才改写,标准上游零改动且幂等。接入三处:Responses 透传流式主路径 `runResponsesFold` 在逐事件解析后统一归一化(顺带让 preflight 语义判断与 thinkingcache 收集对 OpenRouter 流生效),兜底逐行中继路径 `processLine` 按行归一化(仅含 reasoning 字样的行才解析 JSON),非流式 `ConvertToResponsesResponse` 在类型化转换前归一化响应体;转 Claude 协议的 `HandleStreamResponse` 原本已双兼容两种事件名无需改动

### 修复

Expand Down
143 changes: 143 additions & 0 deletions backend-go/internal/converters/responses_reasoning_normalize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package converters

import (
"encoding/json"
"strings"
)

// OpenRouter 的 Responses API 实现与 OpenAI 规范存在两处系统性偏差:
// 1. 非流式:reasoning 输出条目把思考文本放在非标准 content[](part 类型 reasoning_text)里,
// 而规范位置 summary[] 留空;
// 2. 流式:思考增量用非标准事件 response.reasoning_text.delta/.done,
// 且 content_part.added/.done 携带 reasoning_text part,
// 而非规范的 response.reasoning_summary_text.* / response.reasoning_summary_part.* 与 summary_text part。
//
// 按 OpenAI 规范实现的客户端(Codex CLI 等)只读 summary / summary 系事件,
// 导致经 OpenRouter 中转的推理模型思考内容不可见。本文件将上述形态归一化为规范形态。
//
// 所有改写均按数据形状触发:仅当事件名精确命中或 summary 为空且 content 全部为
// reasoning_text 时才改写,标准上游的响应不受影响,重复调用幂等。

// openRouterReasoningEventRenames 需要改名的非标准流式事件 -> 规范事件名。
var openRouterReasoningEventRenames = map[string]string{
"response.reasoning_text.delta": "response.reasoning_summary_text.delta",
"response.reasoning_text.done": "response.reasoning_summary_text.done",
}

// NormalizeOpenRouterReasoningEvent 就地归一化单条 Responses SSE 事件,
// 返回是否发生改写。标准事件原样保留。
func NormalizeOpenRouterReasoningEvent(event map[string]interface{}) bool {
eventType, _ := event["type"].(string)
if eventType == "" {
return false
}

switch eventType {
case "response.output_item.added", "response.output_item.done":
item, _ := event["item"].(map[string]interface{})
if item == nil {
return false
}
return normalizeResponsesReasoningItem(item)
case "response.content_part.added", "response.content_part.done":
part, ok := event["part"].(map[string]interface{})
if !ok {
return false
}
if partType, _ := part["type"].(string); partType != "reasoning_text" {
return false
}
suffix := strings.TrimPrefix(eventType, "response.content_part.")
part["type"] = "summary_text"
event["type"] = "response.reasoning_summary_part." + suffix
renameContentIndexToSummaryIndex(event)
return true
default:
renamed, ok := openRouterReasoningEventRenames[eventType]
if !ok {
return false
}
event["type"] = renamed
renameContentIndexToSummaryIndex(event)
return true
}
}

// NormalizeOpenRouterReasoningResponseBody 就地归一化 Responses 非流式响应体中
// reasoning 条目的非标准 content 形态,返回是否发生改写。
func NormalizeOpenRouterReasoningResponseBody(body map[string]interface{}) bool {
output, ok := body["output"].([]interface{})
if !ok {
return false
}
changed := false
for _, rawItem := range output {
if item, ok := rawItem.(map[string]interface{}); ok && normalizeResponsesReasoningItem(item) {
changed = true
}
}
return changed
}

// NormalizeOpenRouterReasoningSSELine 对透传中继路径的单行 SSE 做归一化。
// 仅 data 行且负载含 reasoning 字样时才解析 JSON,其余行原样返回以保持零开销。
func NormalizeOpenRouterReasoningSSELine(line string) string {
payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data:")
if !ok || !strings.Contains(payload, "reasoning") {
return line
}
var event map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(payload)), &event); err != nil {
return line
}
if !NormalizeOpenRouterReasoningEvent(event) {
return line
}
normalized, err := json.Marshal(event)
if err != nil {
return line
}
return "data: " + string(normalized)
}

// normalizeResponsesReasoningItem 将 reasoning 条目中非标准的 content[reasoning_text]
// 迁移为规范的 summary[summary_text],返回是否发生改写。
// summary 已有内容、content 含未知 part 类型时保守跳过,避免破坏异构上游的自定义扩展。
func normalizeResponsesReasoningItem(item map[string]interface{}) bool {
if itemType, _ := item["type"].(string); itemType != "reasoning" {
return false
}
if summary, ok := item["summary"].([]interface{}); ok && len(summary) > 0 {
return false
}
content, ok := item["content"].([]interface{})
if !ok || len(content) == 0 {
return false
}
parts := make([]interface{}, 0, len(content))
for _, rawPart := range content {
part, ok := rawPart.(map[string]interface{})
if !ok {
return false
}
if partType, _ := part["type"].(string); partType != "reasoning_text" {
return false
}
parts = append(parts, map[string]interface{}{"type": "summary_text", "text": part["text"]})
}
item["summary"] = parts
delete(item, "content")
return true
}

// renameContentIndexToSummaryIndex 规范的 summary 系事件用 summary_index 标记分片序号,
// OpenRouter 复用了 content_index;语义相同,改名即可。
func renameContentIndexToSummaryIndex(event map[string]interface{}) {
if _, has := event["summary_index"]; has {
return
}
if idx, ok := event["content_index"]; ok {
event["summary_index"] = idx
delete(event, "content_index")
}
}
238 changes: 238 additions & 0 deletions backend-go/internal/converters/responses_reasoning_normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package converters

import (
"encoding/json"
"strings"
"testing"
)

func mustEvent(t *testing.T, raw string) map[string]interface{} {
t.Helper()
var event map[string]interface{}
if err := json.Unmarshal([]byte(raw), &event); err != nil {
t.Fatalf("unmarshal event: %v", err)
}
return event
}

func TestNormalizeOpenRouterReasoningEvent(t *testing.T) {
tests := []struct {
name string
in string
wantChanged bool
wantType string
wantSummary interface{}
}{
{
name: "reasoning_text_delta 改名为 summary_text 并迁移索引",
in: `{"type":"response.reasoning_text.delta","item_id":"rs_1","output_index":0,"content_index":0,"delta":"思考"}`,
wantChanged: true,
wantType: "response.reasoning_summary_text.delta",
wantSummary: nil,
},
{
name: "reasoning_text_done 改名",
in: `{"type":"response.reasoning_text.done","item_id":"rs_1","text":"思考完成"}`,
wantChanged: true,
wantType: "response.reasoning_summary_text.done",
},
{
name: "content_part_added 的 reasoning_text part 转为 summary part",
in: `{"type":"response.content_part.added","item_id":"rs_1","output_index":0,"content_index":0,"part":{"type":"reasoning_text","text":""}}`,
wantChanged: true,
wantType: "response.reasoning_summary_part.added",
},
{
name: "content_part_done 携带完整文本",
in: `{"type":"response.content_part.done","part":{"type":"reasoning_text","text":"完整思考"}}`,
wantChanged: true,
wantType: "response.reasoning_summary_part.done",
},
{
name: "标准 summary 事件不动",
in: `{"type":"response.reasoning_summary_text.delta","summary_index":0,"delta":"x"}`,
wantChanged: false,
wantType: "response.reasoning_summary_text.delta",
},
{
name: "output_text 事件不动",
in: `{"type":"response.output_text.delta","delta":"hi"}`,
wantChanged: false,
wantType: "response.output_text.delta",
},
{
name: "无 type 的事件不动",
in: `{"foo":"bar"}`,
wantChanged: false,
wantType: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := mustEvent(t, tt.in)
changed := NormalizeOpenRouterReasoningEvent(event)
if changed != tt.wantChanged {
t.Fatalf("changed = %v, want %v", changed, tt.wantChanged)
}
if got, _ := event["type"].(string); got != tt.wantType {
t.Fatalf("type = %v, want %v", got, tt.wantType)
}
if _, has := event["content_index"]; has && changed {
t.Fatalf("content_index should be renamed to summary_index: %#v", event)
}
})
}
}

func TestNormalizeOpenRouterReasoningEventOutputItem(t *testing.T) {
event := mustEvent(t, `{
"type": "response.output_item.done",
"item": {
"id": "rs_1",
"type": "reasoning",
"status": "completed",
"summary": [],
"content": [{"type": "reasoning_text", "text": "17*20=340"}]
}
}`)
if !NormalizeOpenRouterReasoningEvent(event) {
t.Fatal("expected change")
}
item := event["item"].(map[string]interface{})
if _, has := item["content"]; has {
t.Fatalf("content field should be removed: %#v", item)
}
summary, ok := item["summary"].([]interface{})
if !ok || len(summary) != 1 {
t.Fatalf("summary = %#v, want one part", item["summary"])
}
part := summary[0].(map[string]interface{})
if part["type"] != "summary_text" || part["text"] != "17*20=340" {
t.Fatalf("summary part = %#v", part)
}

// 幂等:再次调用不再改动
if NormalizeOpenRouterReasoningEvent(event) {
t.Fatal("second call should be a no-op")
}
}

func TestNormalizeOpenRouterReasoningEventKeepsStandardItem(t *testing.T) {
event := mustEvent(t, `{
"type": "response.output_item.done",
"item": {
"id": "rs_2",
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "官方形态"}]
}
}`)
if NormalizeOpenRouterReasoningEvent(event) {
t.Fatal("standard item should not be touched")
}
}

func TestNormalizeOpenRouterReasoningEventSkipsUnknownContentParts(t *testing.T) {
event := mustEvent(t, `{
"type": "response.output_item.done",
"item": {
"id": "rs_3",
"type": "reasoning",
"summary": [],
"content": [{"type": "reasoning_text", "text": "a"}, {"type": "custom", "text": "b"}]
}
}`)
if NormalizeOpenRouterReasoningEvent(event) {
t.Fatal("unknown content part types should be left untouched")
}
}

func TestNormalizeOpenRouterReasoningResponseBody(t *testing.T) {
body := map[string]interface{}{
"id": "resp_1",
"output": []interface{}{
map[string]interface{}{
"id": "rs_1",
"type": "reasoning",
"summary": []interface{}{},
"content": []interface{}{map[string]interface{}{"type": "reasoning_text", "text": "思考"}},
},
map[string]interface{}{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": []interface{}{map[string]interface{}{"type": "output_text", "text": "391"}},
},
},
}
if !NormalizeOpenRouterReasoningResponseBody(body) {
t.Fatal("expected change")
}
reasoning := body["output"].([]interface{})[0].(map[string]interface{})
if _, has := reasoning["content"]; has {
t.Fatalf("reasoning content should be removed: %#v", reasoning)
}
message := body["output"].([]interface{})[1].(map[string]interface{})
if _, has := message["content"]; !has {
t.Fatal("message content must be preserved")
}

// 标准响应体零改动
standard := map[string]interface{}{
"output": []interface{}{
map[string]interface{}{
"id": "rs_2",
"type": "reasoning",
"summary": []interface{}{map[string]interface{}{"type": "summary_text", "text": "官方"}},
},
},
}
if NormalizeOpenRouterReasoningResponseBody(standard) {
t.Fatal("standard body should not be touched")
}
}

func TestNormalizeOpenRouterReasoningSSELine(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "非 data 行原样返回",
in: "event: response.created",
want: "event: response.created",
},
{
name: "DONE 行原样返回",
in: "data: [DONE]",
want: "data: [DONE]",
},
{
name: "不含 reasoning 的行不解析",
in: `data: {"type":"response.output_text.delta","delta":"hi"}`,
want: `data: {"type":"response.output_text.delta","delta":"hi"}`,
},
{
name: "畸形 JSON 原样返回",
in: `data: {"type":"response.reasoning_text.delta","delta":`,
want: `data: {"type":"response.reasoning_text.delta","delta":`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeOpenRouterReasoningSSELine(tt.in); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}

line := `data:{"type":"response.reasoning_text.delta","content_index":0,"delta":"想"}`
got := NormalizeOpenRouterReasoningSSELine(line)
if !strings.HasPrefix(got, "data: ") || !strings.Contains(got, "reasoning_summary_text.delta") {
t.Fatalf("unexpected normalization: %q", got)
}
if strings.Contains(got, "content_index") {
t.Fatalf("content_index should be renamed: %q", got)
}
}
Loading