diff --git a/.gitignore b/.gitignore index 90f944b..8d6dca7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,10 @@ sub2api/ hack/config.yaml manifest/config/config.yaml + +# 防误写保险:语料默认输出在仓库外(../team-api-corpus / TEAM_API_CORPUS_DIR), +# 万一有人 -o 指回仓库内也不至于误提交。见 docs/协议转换语料采集手册.md §4 +relaykit/relayconvert/register/golden/real_inputs/ +relaykit/relayconvert/register/golden/real_stream_inputs/ +relaykit/relayconvert/register/golden/real_responses/ +relaykit/relayconvert/register/golden/corpus_manifest.json diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index dbb1838..3b9f9f6 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -67,6 +67,7 @@ type ChannelCreateReq struct { Remark string `json:"remark" dc:"备注"` IsVIP bool `json:"is_vip" d:"false" dc:"是否VIP专属渠道"` UseProxy bool `json:"use_proxy" d:"false" dc:"启用代理"` + WebSearchToGoogleSearch bool `json:"web_search_to_google_search" d:"false" dc:"web_search→googleSearch 映射(Gemini 渠道,搜索按次另行计价)"` SharingThreshold float64 `json:"sharing_threshold" d:"0.6" dc:"普通租户借用阈值"` PreemptionThreshold float64 `json:"preemption_threshold" d:"0.8" dc:"VIP抢占阈值"` BorrowingCooldownSeconds int `json:"borrowing_cooldown_seconds" d:"30" dc:"被抢占后冷却时间(秒)"` @@ -95,6 +96,7 @@ type ChannelUpdateReq struct { StrictCapacity *bool `json:"strict_capacity" dc:"严格容量(fail-closed)"` IsVIP *bool `json:"is_vip" dc:"是否VIP专属渠道"` UseProxy *bool `json:"use_proxy" dc:"启用代理"` + WebSearchToGoogleSearch *bool `json:"web_search_to_google_search" dc:"web_search→googleSearch 映射(Gemini 渠道,留空不更新)"` DebugLogEnabled *bool `json:"debug_log_enabled" dc:"启用渠道调试日志(记录四段完整报文,排障用,用完及时关闭)"` DebugLogTenantID *int64 `json:"debug_log_tenant_id" dc:"调试目标租户ID过滤(0=不限)"` DebugLogUserID *int64 `json:"debug_log_user_id" dc:"调试目标成员ID过滤(0=不限)"` @@ -133,6 +135,7 @@ type ChannelDetailRes struct { Remark string `json:"remark"` IsVIP bool `json:"is_vip"` UseProxy bool `json:"use_proxy"` + WebSearchToGoogleSearch bool `json:"web_search_to_google_search"` DebugLogEnabled bool `json:"debug_log_enabled"` DebugLogTenantID int64 `json:"debug_log_tenant_id"` DebugLogUserID int64 `json:"debug_log_user_id"` diff --git a/docs/relaykit迁移工作状态.md b/docs/relaykit迁移工作状态.md new file mode 100644 index 0000000..eeec54d --- /dev/null +++ b/docs/relaykit迁移工作状态.md @@ -0,0 +1,305 @@ +# relay → relaykit 协议转换迁移 · 工作状态 + +> 记录时间:2026-09-10(上一版 2026-09-09) +> 分支:`feat/relaykit`(基于 main,领先 2 个已提交 commit + 大量未提交工作区改动) +> 状态:**未提交**。全仓 `go build ./...` + `go vet` 通过;relaykit 模块测试全绿; +> relay 侧仅剩既有失败(见 §四)。§5.1 已修,另发现并修复 **2 个本次迁移引入的回归**(见 §3.6)。 + +--- + +## 一、本次任务目标 + +把 relay 层残留的「纯协议转换」代码全部迁进独立模块 `relaykit/`,让 relaykit 成为唯一转换路径, +宿主 `relay/` 只保留 HTTP/SSE/计费/调度等宿主职责。 + +## 二、开工前定下的 4 个决策(用户已拍板) + +| 决策点 | 选定方案 | +|--------|----------| +| 注册表改造 | **允许单侧注册**(请求侧或响应侧可单独为空),反向方向才注册得进去 | +| 跨原生方向表达 | **中枢链式 + 直连覆盖**:请求走 `A→OpenAI→B` 步骤链,响应用直连转换器(保真度高)。
后修订:Claude→Gemini 请求侧改为直连(见 §六.11) | +| legacy 兜底策略 | **立即 hard-fail**:不再"解析失败回退旧实现",legacy 随迁随删 | +| 迁移范围 | 全选:4 个跨原生响应桥 + openai/converter.go 反向方向 + o2c/o2g 双实现清理 + 小方向补全 | + +## 三、已完成的工作 + +### 3.1 relaykit 注册表基建(新增/改造) + +| 文件 | 说明 | +|------|------| +| `relaykit/relayconvert/text_converter_registry.go` | 放宽双侧非空约束 → 单侧注册(两侧都空才 panic),只把已配置的一侧写进对应注册表 | +| `relaykit/relayconvert/engine.go`(新增) | `ConvertRequestByID` / `ExecuteRequestConversion`:统一执行直连转换器与 StepConverters 步骤链,调用方无需区分 | +| `relaykit/relayconvert/stream_event.go`(新增) | `StreamEvent{Event, Data, Usage *dto.UsageWithDetails}`:非 OpenAI 客户端方向的通用流式帧封装(Event 非空=事件帧,空=纯 data 帧) | +| `relaykit/relayconvert/errors.go`(新增) | `ErrStatefulResponsesUnsupported` 哨兵(顶层包,宿主可 `errors.Is`) | +| `relaykit/relayconvert/convmeta/responses_stash.go`(新增) | `ResponsesStash` 能力接口:Responses 请求快照 stash/读取 | +| `request_registry.go` / `response_registry.go` | 补齐全部方向的 converter ID 常量(含跨原生、反向、Responses 双向、流式) | + +**注意**:`StreamEvent.Usage` 中途从 `*dto.Usage` 升级为 `*dto.UsageWithDetails`(瘦类型会丢缓存明细 → 影响计费), +所有转换器已按新契约适配。 + +### 3.2 转换器迁移(4 个并行子任务,全部完成且测试通过) + +| 子任务 | 产出包 | 内容 | +|--------|--------|------| +| A | `relaykit/relayconvert/internal/oai_chat/` | Claude 入站方向:`ClaudeToOpenAIRequestConverter`、`OpenAIToClaudeResponseConverter`、`OpenAIToClaudeStreamConverter` | +| B | `relaykit/relayconvert/internal/oai_gemini/` | Gemini 入站方向:`GeminiToOpenAIRequestConverter`、`OpenAIToGeminiResponseConverter`、`OpenAIToGeminiStreamConverter` | +| C | `relaykit/relayconvert/internal/oai_responses/`(新包)+ `relaykit/dto/openai_responses.go`(新) | Responses 双向 6 个转换器;Responses DTO 下沉到 relaykit,`relay/dto/openai_responses.go` 改为纯别名文件 | +| D | `internal/claude_gemini/` + `internal/native_responses/`(新包) | 8 个跨原生响应转换器(Claude↔Gemini、Claude/Gemini→Responses,各含流式) | + +### 3.3 注册接线 + +`relaykit/relayconvert/register/register.go` 现注册 **12 个方向**: + +- OpenAI 入站 → Claude / Gemini / Ollama(chat)(原有) +- 反向:Claude / Gemini / Responses 入站 → OpenAI 上游(新) +- OpenAI chat → Responses 上游(ChatViaResponses 桥接,新) +- 跨原生:Claude→Gemini、Gemini→Claude、Responses→Claude、Responses→Gemini + (新;响应侧全部直连,请求侧 Claude→Gemini 已改直连,其余仍为步骤链) + +### 3.4 宿主桥接层改造 + +| 文件 | 说明 | +|------|------| +| `relay/relaykit_bridge/route.go`(**新增**) | **唯一权威方向矩阵**:`KitFormat`(宿主↔relaykit 格式名映射,Responses 命名不同需显式转)、`EffectiveUpstreamFormat`(含 UseResponsesAPI / UpstreamSpeaksResponses 覆盖)、`RequestConverterIDForRoute`、`ResponseConverterIDForRoute`。另在此包 blank-import `register` 触发注册(放桥接包而非某调用方,保证所有 channel 包及其单测都能拿到注册结果) | +| `relay/relaykit_bridge/response.go` | 签名改 4 值 `(converted, usage, handled, err)`,hard-fail 语义;按格式解析上游响应(新增 OpenAI/Responses 两种) | +| `relay/relaykit_bridge/stream.go` | chunkWriter 支持 `StreamEvent`(事件帧/data 帧分派 + usage 明细捕获);**收尾策略按客户端格式**:OpenAI/Gemini 客户端写 `[DONE]`(OpenAI 另补终止 chunk),Claude/Responses 客户端不写(终止语义由 message_stop / response.completed 承载) | +| `relay/handler/relaykit_bridge.go` | 重写:走统一矩阵、按入站格式解析请求、hard-fail、OpenAI 上游后处理(stream_options / reasoning_effort 注入,均"客户端未显式设置时"语义)、哨兵错误映射 | +| `relay/handler/relay_handler.go` | `convertRequestBody` 改三态处理(handled+err / handled / 未接管走 adaptor) | +| `relay/common/relay_info.go` | `*RelayInfo` 实现 `convmeta.ResponsesStash` | + +### 3.5 宿主 legacy 删除(hard-fail 接线) + +已改为纯桥接外壳并删除旧转换实现: + +- `relay/channel/claude/response.go`(handleNonStreamToOpenAI / handleStreamToOpenAI,删约 270 行流式循环) +- `relay/channel/gemini/response.go`(同上,删约 300 行) +- `relay/channel/ollama/adaptor.go`(chat 非流式+流式,删约 150 行) +- `relay/channel/openai/claude_response.go`(重写为桥接外壳) +- `relay/channel/openai/gemini_response.go`(重写为桥接外壳,保留 `writeOpenAIErrorAsGemini` 等错误映射) +- 4 个跨原生桥 `claude/gemini_bridge.go`、`claude/responses_bridge.go`、`gemini/claude_bridge.go`、`gemini/responses_bridge.go`(由子任务改造,**见待办**) + +--- + +## 四、当前验证状态 + +``` +GOTOOLCHAIN=go1.25.8 go build ./... ✅ 通过 +GOTOOLCHAIN=go1.25.8 go vet ./relay/... ✅ 通过 +cd relaykit && GOWORK=off go test -count=1 ./... ✅ 全绿(14 个包) +GOTOOLCHAIN=go1.25.8 go test ./relay/... ✅ 仅 relay/helper 既有失败(见 §六.7) +GOTOOLCHAIN=go1.25.8 go test ./internal/... ⚠️ 3 个既有失败,与本次迁移无关: + · internal/logic/admin TestBuiltinRoleDefaultsMatchMigration + —— 找不到 000020_admin_role_management.sql(迁移已重编号为 000020_v0_2_17_version.sql) + · internal/dispatchadapter TestProbeFailure_DoesNotDecayHealthEwma —— 健康分 EWMA + · internal/logic/billing TestGenerateBillingSummary_TimeRule —— 账单文案时段行 + (三处工作区均无改动,勿误当回归) +gofmt -l relay/ relaykit/ ⚠️ 剩 10 个**存量**未格式化文件 + (ali/volcengine converter_test、relaykit dto/audio、goldentest、benchmark_test 等, + 本次迁移触及的 5 个文件已 gofmt -w) +``` + +--- + +## 五、重启后的待办 + +### 5.1 ~~修复 3 个流式错误传播测试~~ ✅ 已完成(2026-09-10) + +**上一版留下的坑**:这 3 个测试当时是被「反转断言」改绿的——把「应返回错误」改成 +「不返回错误、只查 StreamStatus」。但 `StreamEndReasonError` **不在** `IsPartialStreamEnd()` +集合内(`relay/common/stream_status.go:79`),于是 `DoResponse` 返回 `(usage, nil)`, +落到 `relay_handler.go` 的**成功路径**:`sess.Finish(true)` 让调度 FSM 收到「成功」、 +熔断/健康分拿不到上游故障信号,并按正常成功结算(usage 近零)。旧实现是返回 error 的。 + +**已按原文档倾向的方案 1 修正**: +- `TryConvertStreamViaRelaykit` 签名改三态 `(*common.Usage, bool, error)`; + `ok=true,err!=nil` = 已写出 SSE 但流以错误/中断结束,调用方原样 `return usage, err`。 +- 新增 `streamConvertError`(`relay/relaykit_bridge/stream.go`)做错误分类, + 一律置 `ResponseWritten`(SSE 头与 200 已提交,不得改状态码/换渠道重试)。 +- 客户端断开分支直接透出 `common.ErrStreamInterrupted`,9 个调用点不再各自 + 读 `IsPartialStreamEnd()` 判定,统一收敛到桥接层。 +- 3 个测试断言改回「应返回错误」,并加断分类与收尾事件。 + +**顺带修正的分类错误**:安全拦截(Gemini `promptFeedback.blockReason`)原先会被映射成 +502 上游错误 → 正常渠道因用户发违规提示词被扣健康分乃至熔断。现新增 +`relaykit/relayconvert.ErrContentBlocked` 哨兵(6 个 Gemini 出站转换器 wrap 返回), +宿主 `errors.Is` 识别后映射为请求类 4xx。非流式侧同理,新增 +`relaykit_bridge.ResponseConvertError` 供 3 个 Gemini 上游非流式站点统一分类。 +6 个方向(→Claude / →Responses / →OpenAI × 流式/非流式)均已有测试覆盖。 + +### 5.2 ~~核对子任务 D 的跨原生桥接线成果~~ ✅ 已核对(结论与担心相反) + +`geminiToClaudeResponse` / `buildResponsesBodyFromGemini` **仍在且不能删**: +它们是 Code Assist 强制流式聚合路径(`gemini/adaptor.go:514,520` +`handleCodeAssistAggregatedStream`)复用的非流式装配函数,各文件头注释已写明。 +计费 usage 已确认仍从**原始上游响应体**提取。 + +### 5.3 ~~收尾清理~~ ✅ 已完成(2026-09-10,legacy 删除落地) + +删除过程中发现并补齐了 2 处迁移遗漏(见 §七的「删除时补的接线」)。 + +### 5.4 提交前检查 + +- `gofmt -l` 本次触及文件已清空(存量 10 个未动,避免污染本次 diff) +- `go vet ./relay/... ./internal/...` ✅ +- 提交信息不得包含 AI 工具身份标识(项目规范) +- 建议拆 commit:注册表基建 / 转换器迁移 / 宿主接线 / legacy 删除 / + **本次新增:流式错误传播 + 安全拦截分类 / Anthropic 端点与 provider 后处理回归修复** + +--- + +## 3.6 本次新发现并修复的 2 个回归(重点,评审必看) + +两者同一个根因:**relaykit 的转换矩阵按「协议格式」裁决、与供应商无关**, +命中后 `adaptor.ConvertRequest` 整个不再被调用。而 §3.3 新增的「非 OpenAI 入站 → +OpenAI 上游」三个反向方向,把原本归 adaptor 的路径整片接管走了。 + +### 回归 A:Anthropic 兼容端点渠道的 Claude 入站双向断链(严重) + +ali / zhipu / deepseek / moonshot / volcengine / minimax 这 6 家主协议是 OpenAI, +但对 `RelayModeClaudeMessages` **另挂独立的 Anthropic 兼容端点** +(`/anthropic/v1/messages`、`/apps/anthropic/v1/messages`、`/api/coding/v1/messages`), +Claude 入站时上游**实际说 Claude**:请求体原样发、响应体也是 Claude +(其 `DoResponse` 正是委托给 `claude.Adaptor`)。 + +但 `helper.ProviderNativeFormat` 只按渠道类型返回单一格式(这几家 → openai), +且 `inboundMatchesChannelNative` 因此判 false 使 `canPassThrough` 也不兜底,于是: +- 请求侧:Claude 体被 relaykit 转成 OpenAI chat,POST 到 Anthropic 端点 → 上游 400; +- 响应侧:返回的 Claude 响应体被当 OpenAI 解析 → 转换失败。 + +**修复**:新增 `constant.HasNativeClaudeEndpoint(providerType)`,在 +`relaykit_bridge.EffectiveUpstreamFormat` 与 `handler.inboundMatchesChannelNative` +两处叠加判定 —— Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式、矩阵返回空串 +⇒ 不转换,回到 adaptor / 直连原路径。 + +> 登记判据:`GetRequestURL` 对 `ClaudeMessages` 返回了与 chat 端点**不同**的地址。 +> 共用同一端点的(aws / baidu_v2 / mistral / openai / xai / xunfei)上游只说 OpenAI, +> 不属于本集合,Claude 入站仍须转换。新增此类供应商时必须同步登记。 + +### 回归 B:5 个供应商的私有请求后处理被绕过 + +xai / baidu_v2 / zhipu / ali / deepseek 在格式转换之外还有私有请求适配, +relaykit 只补了通用的 `model` / `stream_options` / `reasoning_effort`: + +| provider | 丢失的后处理 | 后果 | +|---|---|---| +| xai | `-search`/`-high`/`-low` 后缀剥离 + `search_parameters`/`reasoning_effort` | 未配映射时 `grok-4-search` 原样发上游 → 400 | +| baidu_v2 | `-search` 后缀剥离 + `web_search` 注入 | 同上 | +| zhipu | `applyGLMCompatibility`(top_p 裁剪、图片前缀剥离)+ 思考参数方言 | GLM 参数越界、思考失效 | +| ali | DashScope 按上游模型剥离 `thinking_budget` | 上游拒绝不支持该参数的模型 | +| deepseek | `thinking` 对象(V3 `type` + R1 `reasoning_effort:"none"`) | `-thinking`/`-nothinking` 静默失效 | + +模型名那条最实际:未配映射时 `UpstreamModel = row.ModelName` +(`internal/dispatchadapter/catalog.go:443`)**带着后缀**,relaykit 无条件写 +`GetUpstreamModelName()`,而 adaptor 本来会剥掉后缀再写回。 + +**修复**:新增可选能力接口 `common.RequestPostProcessor` +(`PostProcessConvertedRequest(ctx, info, body) ([]byte, error)`), +`handler.convertRequestBody` **仅在 relaykit 接管时**调用(走 adaptor 那条路径的 +`ConvertRequest` 内部已含同一段,重复执行会双写)。5 家各把自己 `ConvertRequest` +的尾段抽成 `postProcessRequest`,与钩子共用同一实现,杜绝两份漂移。 + +已核对**不受影响**:minimax / moonshot 的 Claude 入站分支只做模型名映射(relaykit 已覆盖); +其余约 15 个 OpenAI 兼容 provider 的 `ConvertRequest` 无格式外后处理。 + +--- + +## 七、legacy 删除完成记录(2026-09-10 已全部落地) + +净删约 **6400 行**(git diff --stat:+530 / -6907)。逐项: + +| 项 | 结果 | +|----|------| +| `openai/converter.go` o2r 段(`ConvertOpenAIToResponses` + c2r 助手) | ✅ 删除(chat→Responses 请求转换由 relaykit `ConverterOpenAIChatToOpenAIResponses` 接管,handler 桥注入 reasoning_effort) | +| `openai/converter.go` c2o/g2o/r2o(`ConvertClaudeToOpenAI` 等) | ⚠️ **保留**:Ollama 渠道的 Claude/Gemini/Responses 入站走 `ConvertToOpenAI`→OpenAI→Ollama 链(矩阵未注册 Claude→Ollama 方向,属「未迁方向保留」设计) | +| `openai/responses.go` `handleResponsesInboundStream`/`NonStream`(约 600 行) | ✅ 改为 relaykit 桥接壳(**这是迁移漏掉的接线**——转换器早注册了但 openai adaptor 一直在调 legacy 实现);孤儿助手 `chatCompletionToResponsesResponse`/`extractStreamEmbeddedError` 删除,`extractResponsesRequestEcho` 保留(`BuildResponsesObjectMap` 导出依赖) | +| `claude/converter.go`(446 行)+ `converter_test.go` | ✅ 整文件删除(o2c 请求侧全套) | +| `gemini/converter.go`(686 行)+ `converter_test.go` | ✅ 整文件删除(o2g 请求侧全套) | +| 18 个 adaptor 的 `ConvertToOpenAI` 守卫 | ✅ 删 17 个(死代码:非 OpenAI 入站→OpenAI 上游已全由矩阵接管);**ollama 保留**(见上) | +| claude/gemini/openai adaptor 的入站格式 switch | ✅ 删除,改为同格式路径 + **hard-fail 守卫**(矩阵意外未接管时显式报错,不静默错格式透传) | +| `deepseek` `convertResponsesToChatForDeepSeek` | ✅ 删除,分支改 hard-fail(relaykit Responses→OpenAI + PostProcess 钩子接管) | +| stale 注释(passthrough/bridge 头注释中的旧函数名) | ✅ 更新为 relaykit 表述 | + +**删除时补的接线**(不做这两步直接删会断链): +1. **openai adaptor responses 入站桥接**:迁移只注册了转换器、没改 adaptor 调用点。已把两个 inbound 处理器改为调 `TryConvertStream/ResponseViaRelaykit`(非 200 透传语义保留,计费仍取上游原始 chat 体口径)。 +2. **Responses 客户端流错误收尾**:桥接层在转换器以错误退出且未产出任何终止态事件时补发 `response.failed`(否则 200 SSE 静默结束、codex 等客户端挂起);转换器已自行收尾的(如 Gemini SAFETY 发 completed 拒答)不追加第二终止事件。相关测试已按新契约改写。 + +**验证**:`go build ./...` ✅、`go vet ./relay/... ./internal/...` ✅、relaykit 模块全绿 ✅、 +relay 测试仅剩 §六.7 既有失败 ✅、触及文件 gofmt 干净 ✅。 + +## 六、重要设计约束备忘(重启后容易忘) + +1. **格式名两侧不同**:宿主 `constant.RelayFormatResponses = "responses"`,relaykit `types.RelayFormatOpenAIResponses = "openai_responses"`。 + 跨模块必须走 `relaykit_bridge.KitFormat()`,**不能直接强转**(其余格式字符串值相同可强转)。 +2. **计费 usage 一律取上游原始口径**,不从转换后的响应体取。OpenAI 上游方向需置 + `CacheIncludedInPrompt = true`(prompt 含 cached,计费按明细扣减,避免双重计费)。 +3. **转换器只能依赖 `convmeta.Meta` 接口**,禁止 import 宿主 `relay/...` 或 GoFrame。 + 需要宿主状态时走能力接口(如 `ResponsesStash`)+ 类型断言,断言失败静默降级。 +4. **`relay/dto` 是 `relaykit/dto` 的类型别名**(`relaykit_types.go` + `openai_responses.go`),两侧同型,边界零转换。 +5. **转换器 ID 元数据方法(ID/From/To/Quality)与注册用的配对 spec ID 不是一回事**: + 注册以 `RegisterTextConverter` 传入的 spec.ID 为准,元数据方法只是自描述。 +6. relaykit 是独立 module:测试要 `cd relaykit && GOWORK=off go test ./...`; + 宿主构建要 `GOTOOLCHAIN=go1.25.8`(否则 stdlib 重编译失败)。 +7. `relay/helper` 的 `TestSafeUpstreamErrorMessage_*` 失败是**既有问题**(中英文案期望不一致), + 与本次迁移无关,不要误当回归去修。 +8. **矩阵按格式裁决、与供应商无关**——这是本次两个回归的共同根因。新增/修改方向时必须自问: + ① 该供应商对这个 relay mode 是否另有原生端点(→ `constant.HasNativeClaudeEndpoint` 类判定)? + ② 该供应商的 `ConvertRequest` 里除格式转换外是否还有私有适配 + (→ 实现 `common.RequestPostProcessor`)?漏掉任一条都是**静默**降级或断链。 +9. **`ProviderNativeFormat` 是「按渠道类型的单一格式」,不是全部真相**:多协议供应商的 + 有效上游格式取决于 (供应商, relay mode),权威计算在 `relaykit_bridge.EffectiveUpstreamFormat`, + `handler.inboundMatchesChannelNative`(直连判定)必须与它保持同步,否则直连与转换两条路会对同一 + 请求给出不同格式结论。 +10. **provider 后处理钩子只在 relaykit 路径调用**:adaptor 路径的 `ConvertRequest` 内部已含同一段, + 两处都调会双写参数。5 家的实现均把尾段抽成 `postProcessRequest` 与钩子共用,改动时不要只改一处。 +11. **跨原生请求侧优先直连,不要新增中枢步骤链**:Claude→Gemini 已由 + `internal/claude_gemini/ClaudeToGeminiRequestConverter` 直连。链式(`StepConverters`)会让 + 中间格式承载不了的字段静默消失——实测丢 thinking 签名、精确 thinking budget(5000 被放大成 8192)、 + `top_k`、`tool_use.id` 四项,见 `claude_to_gemini_request_test.go` 的 + `TestClaudeToGeminiRequest_PreservesWhatChainLoses`。新增跨原生方向时先写直连转换器, + 链式只作过渡手段。Gemini 的 JSON Schema 关键字白名单在 `internal/shared.CleanGeminiToolParams`, + OpenAI→Gemini 与 Claude→Gemini 两条路径共用一处,改白名单是两边同时生效。 + +--- + +## 八、模型名后缀(虚拟模型)机制移除(2026-09-10) + +经用量核实无任何带后缀模型(`-thinking`/`-nothinking`/`-none`/`-minimal`/`-low`/`-medium`/ +`-high`/`-xhigh`/`-max`/`-search`)在用,整套「模型名后缀 → 虚拟模型」机制作为技术债务移除。 +思考/搜索能力此后一律由**客户端请求参数**表达(`thinking` 对象、`reasoning_effort`、 +`reasoning.effort` 等),网关只做协议转换不做能力注入。 + +### 删除清单 + +**宿主侧:** +- `relay/helper/thinking.go`(`ParseThinkingSuffix` + `ThinkingInfo`)整文件删 +- `resolveRelayModel` 简化为纯目录校验(不再「字面优先 + 剥后缀回退」两段查找) +- `RelayInfo` 删 `ThinkingEnabled`/`ThinkingDisabled`/`ReasoningEffort` 三字段 + 及 `Get/SetReasoningEffort`(convmeta.Meta 相应方法一并删,本就零调用方) +- 各注入器删除:claude `injectClaudeThinking/Effort`、gemini `injectGeminiThinking`、 + openai `injectReasoningEffort/injectResponsesReasoning`、deepseek 三个 inject* + + `isDeepSeekV4Model`、zhipu `injectThinkingParams` +- `canPassThrough` 删 thinking 后缀排除条件;handler 桥删两处 effort 注入(stream_options 保留) +- xai/baidu_v2 的 `-search`/`-high`/`-low` 剥离删除,两家 + deepseek 的 + `RequestPostProcessor` 钩子实现删除(剩余职责 relaykit 已覆盖);**钩子接口保留**, + 现存实现方仅 zhipu(GLM 参数兼容)与 ali(DashScope 参数裁剪) + +**relaykit 侧:** +- `internal/shared/thinking_adapter.go`、`reasoning/` 包整体删除 +- `convmeta.Options` 删 `PreserveThinkingSuffix`;Claude/Gemini Options 删 + `ThinkingAdapter*` 字段(宿主侧写死 TODO 的配置一并清) +- oai_chat 请求转换器不再解析上游模型名后缀 + +### 保留(勿误删) + +- **客户端参数转换线**:`c2oConvertThinkingToReasoningEffort` / `g2oConvertThinkingConfig` + (Claude thinking 对象 / Gemini thinkingConfig → chat `reasoning_effort` 字段)—— + 这是协议转换不是后缀注入,宿主 converter.go 与 relaykit oai_chat 各一份 +- `bil_usage_logs.reasoning_effort` 列与 `UsageRecord.ReasoningEffort` 字段保留 + (dao 生成 + 历史数据),此后恒为空串 +- `RelayInfo.BaseModelName` 字段保留(responses 路由记录在用),现恒等于 OriginModelName + +### 行为变化 + +- 请求带旧后缀名(如 `gpt-4o-thinking`)→ 目录查不到 → **404 model not found** + (原为剥后缀回退命中 + 注入思考参数) +- 名字本身含 `-max` 等字样的真实目录模型(qwen-max)不受影响(本就字面优先) diff --git a/docs/协议转换语料采集手册.md b/docs/协议转换语料采集手册.md new file mode 100644 index 0000000..2ea9149 --- /dev/null +++ b/docs/协议转换语料采集手册.md @@ -0,0 +1,722 @@ +# 协议转换语料采集手册 + +`corpus-extract` 命令的使用说明:如何把线上真实流量转成协议转换层的测试语料。 + +--- + +## 1. 这个工具解决什么问题 + +协议转换层(`relaykit/relayconvert`)有六层测试防线——wire 保真、能力守恒、金样本、 +矩阵守卫、E2E 集成、模糊测试。但这些防线的**输入语料全部是手写的**,而手写语料有一个 +无法自我察觉的盲区:**写的人想不到的形状**。 + +真实客户端(Claude Code、Cursor、各家官方 SDK、自研网关)发出的请求,在嵌套深度、 +字段组合、多轮结构上远比人工构造的刁钻。历史上多个「静默能力丢失」bug——多模态内容被吞、 +链式 ContentPart 丢失、工具循环断链——都出在手写语料覆盖不到的形状上。 +首轮采集(2026-09-12)当天就从语料里抓到一个:Claude Code 的 Grep 工具使用 JSON Schema +2020-12 的 `prefixItems` 元组语法,Gemini 丢弃该关键字后留下无 `items` 的裸数组直接 400 +(`relayconvert/internal/shared/gemini_schema.go` 已修复并加回归测试)。 + +本工具把 `chn_debug_logs`(渠道调试日志)里已经记录的真实报文转成语料文件, +让各层防线用真实形状跑一遍。 + +### 数据从哪来 + +渠道调试功能对开启的渠道**逐次尝试**记录四段完整报文: + +| 段 | 内容 | 本工具用途 | +|----|------|-----------| +| 段1 | 客户端原始请求 | ✅ **请求侧语料**(转换器的输入) | +| 段2 | 网关发给上游的请求 | ❌ 不用(这是转换的**输出**,见 §6.4) | +| 段3 | 上游原始响应(流式为完整 SSE) | ✅ **响应侧语料**(转换器的输入) | +| 段4 | 客户端最终收到的字节 | ❌ 不用(同上) | + +关键点:**四段里只有段1和段3是语料**。段2/段4 是「当时那次转换的输出」, +属于另一种用法(生产一致性校验),本工具不处理。 + +--- + +## 2. 快速开始 + +```bash +# 1) 在管理后台给目标渠道打开「调试日志」,跑一批测试流量 + +# 2) 先空跑,看采到了什么 +team-api corpus-extract --dry-run + +# 3) 确认统计合理后正式提取 +team-api corpus-extract + +# 4) 抽查几个语料文件,确认脱敏效果符合预期 + +# 5) 采集完毕,关掉渠道的调试开关 +``` + +开发环境用 `go run main.go corpus-extract ...` 等价。 + +--- + +## 3. 第一步:采集调试流量 + +### 开启调试 + +管理后台 → 渠道详情 → 编辑,勾选 **启用渠道调试日志**(`debug_log_enabled`)。 + +配套三个过滤器,可缩小采集范围、避免录下无关流量: + +| 字段 | 含义 | +|------|------| +| `debug_log_tenant_id` | 只记录指定租户的请求(0 = 不限) | +| `debug_log_user_id` | 只记录指定成员的请求(0 = 不限) | +| `debug_log_api_key_id` | 只记录指定密钥的请求(0 = 不限) | + +三者是 **AND** 关系,未设置(0)的维度不参与过滤。**建议用专用测试密钥采集**, +既精准又避免混入其他流量。 + +### 采集什么流量 + +#### 首要原则:别手搓请求 + +这个工具的**全部价值**在于跳出「写测试的人能想到的形状」。如果用 curl 手搓请求去采集, +形状依然来自我们自己的想象力,与手写语料是**同源盲区**——只是把手写从测试文件 +挪到了采集脚本里。 + +优先级:**真实客户端 > 官方 SDK 脚本 > 手搓 curl**,最后一档只用来补冷门方向。 + +| 客户端 | 入站格式 | 能采到的特征 | +|--------|----------|--------------| +| Claude Code | claude | 重度工具循环、thinking、长多轮、大 system prompt、prompt caching | +| Codex CLI | responses | Responses 原生形态(其 serde 极严,`function_call` 必带 `arguments` 键) | +| Cursor / Cline / Continue | openai 或 claude | 代码上下文、并行工具调用、结构化输出 | +| Gemini CLI / `@google/genai` | gemini | Gemini 原生 parts 形态、thoughtSignature | +| 官方 SDK(openai-python / anthropic / google-genai) | 各自 | 精确控制特征矩阵,适合补缺口 | + +项目历史上那几条 wire 契约坑(Gemini parts 禁 null、SSE 禁 `[DONE]`、codex 严格 serde) +**全部是官方 SDK 挑出来的**,手搓请求根本碰不到。 + +#### 渠道怎么配才能采全方向 + +路由是**按模型名选渠道**的。所以要让「Claude 格式的请求打到 Gemini 渠道」, +必须在 Gemini 渠道上挂一个模型,再用 Claude 格式去请求那个模型名。 + +最小渠道集:OpenAI / Claude / Gemini / Ollama / Responses 各一个, +然后四种客户端格式分别打过去: + +| 客户端格式 ↓ \ 上游 → | OpenAI | Claude | Gemini | Ollama | Responses | +|---|---|---|---|---|---| +| openai | 直连 | ✓ | ✓ | ✓ | ✓ | +| claude | ✓ | 直连 | ✓ | — | — | +| gemini | ✓ | ✓ | 直连 | — | — | +| responses | ✓ | ✓ | ✓ | — | 直连 | + +打勾的 11 格是转换方向,「直连」也要采(透传路径同样有测试价值)。 +**跨原生方向(claude→gemini、gemini→claude、responses→claude/gemini)最容易漏**—— +日常没人这么配,必须专门造。 + +#### 请求侧特征清单 + +请求侧你能精确控制。建议**先各特征单独一条,再来几条组合**—— +线上两种都存在,而结构指纹会把它们算作不同形状。 + +| 特征 | 怎么触发 | 提示词示例 | +|------|----------|------------| +| 基础对话 | 什么都不加 | 「你好」 | +| 工具定义 | 挂一个 `get_weather` 工具 | 「北京今天天气怎么样」 | +| **多轮工具循环** | 上一条的**第二轮**:带着 `tool_result` 再发一次 | — | +| 并行工具调用 | 挂两个工具,问需要同时查的 | 「北京和上海天气分别怎么样」 | +| 强制工具 | `tool_choice` 指定函数名 | 任意 | +| 图片(data URL) | 上传本地图片 | 「这张图里有什么」 | +| 图片(远程 URL) | 传 http 图片链接 | 同上 | +| 思考 | Claude 开 `thinking` / OpenAI 用 o 系列 `reasoning_effort` / Gemini 开 `thinkingConfig` | 「笼子里鸡兔共 35 个头 94 只脚,各几只」 | +| **联网搜索** | 开 web_search 工具,且问题**必须有时效性** | 「今天有什么 AI 领域的新闻」 | +| 结构化输出 | `response_format` 给 json_schema | 「提取这段文字里的人名和公司」 | +| 停止序列 | 设 `stop` | 任意 | +| 长多轮 | 连续对话 5–6 轮 | 任意 | +| 大 system prompt | 塞几千 token 的系统提示 | 任意 | + +两个容易做错的点: + +- **多轮工具循环必须真跑两轮**。只发第一轮拿到 `tool_call` 是不够的——真正有价值的 + 语料是**第二轮请求**,它包含完整的 `assistant.tool_calls` + `tool_result` 历史结构, + 而工具循环断链的 bug 正出在这个结构的转换上。 +- **联网搜索要问有时效性的问题**。问「什么是快速排序」模型不会去搜, + 工具挂了也白挂,采不到 `groundingMetadata`。 + +#### 响应侧特征:只能诱导 + +响应形状由模型决定,你只能间接诱导: + +| 想采到 | 怎么诱导 | +|--------|----------| +| `finish_reason = length` | `max_tokens` 设成 10 | +| `finish_reason = tool_calls` | 上面的工具调用 | +| 缓存命中(cache_read) | 同一个长 system prompt 连发两次(Claude 需显式 `cache_control`) | +| reasoning tokens | 思考类请求 | +| grounding 元数据 | 时效性问题 + 搜索工具 | + +内容过滤与安全拦截不建议刻意触发。 + +#### 最容易漏的五件事 + +1. **非流式**。绝大多数客户端默认 `stream=true`,而非流式转换器是**完全独立的代码路径**, + 必须专门发 `stream=false` 的请求。这条排第一。 +2. **跨原生方向**(见上面的矩阵)。 +3. **Ollama 上游**——要本地起一个 ollama。 +4. **Responses 入站**——需要 codex 或专门的 Responses 客户端。 +5. **同一特征在不同入站格式下都要采**:`openai__tools` 和 `claude__tools` + 是两份语料、驱动的是不同转换器。 + +#### 建议顺序 + +先用真实客户端(Claude Code / Cursor / codex)自然跑一段时间,把主干路径和 +「想不到的形状」捞进来;再用官方 SDK 脚本按上面的特征表补缺口; +最后跑一次 `--dry-run` 看方向覆盖表,缺哪补哪。 + +配一个**专用测试密钥**,用 `debug_log_api_key_id` 过滤,避免混入其他流量。 + +### 覆盖清单 + +提取完成后命令会打印**转换方向覆盖表**,对照它补采即可。核对四个维度: + +- **四种入站格式**:OpenAI chat(`/v1/chat/completions`)、Claude(`/v1/messages`)、 + Gemini(`/v1beta/...`)、Responses(`/v1/responses`) +- **各上游协议**:至少覆盖 OpenAI / Claude / Gemini / Ollama 各一个渠道 +- **流式与非流式**:两者的转换器是完全不同的代码路径,都要采 +- **能力维度**:工具调用、多轮工具循环、图片、思考、联网搜索 + +### 注意事项 + +- **体积**:`chn_debug_logs` 的 body 字段**完整不截断、无自动过期**。带 base64 图片的 + 请求单条可达数 MB。这是一次**时间盒式的采集战役**(比如几小时或到 N 条为止), + 不是常开功能。 +- **采集完及时关闭调试开关**,并按需清理表数据(无自动清理机制)。 +- **脱敏默认开启**:报文体会做内容脱敏(详见 §7)。`--no-scrub` 可关闭, + 但那样落盘的是原文,仅限确知无敏感数据的测试流量。 + +--- + +## 4. 第二步:提取语料 + +### 命令与参数 + +```bash +team-api corpus-extract [选项] +``` + +| 选项 | 默认值 | 说明 | +|------|--------|------| +| `-o, --output` | `relaykit/relayconvert/register/golden` | 语料输出根目录 | +| `--since` | 无 | 起始时间,支持 `2026-09-01` / `2026-09-01 10:00:00` / RFC3339 | +| `--until` | 无 | 结束时间,格式同上 | +| `--channel` | 无 | 渠道 ID 过滤,逗号分隔,如 `3,7,12` | +| `--format` | 无 | 客户端格式过滤:`openai` / `claude` / `gemini` / `responses` | +| `--limit` | `5000` | 最多扫描的日志行数 | +| `--per-shape` | `1` | 每种结构形状保留几条样本 | +| `--max-inline` | `8192` | 内联 base64 超过该字节数则替换为占位(**仅 `--no-scrub` 时生效**;脱敏开启时所有内联数据都会被换掉) | +| `--no-scrub` | 关 | 关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量) | +| `--keep-tool-names` | 关 | 脱敏时保留自定义工具名原值(内置工具名始终保留) | +| `--include-errors` | 关 | 包含失败尝试(默认只取上游 200 的最终尝试) | +| `--dry-run` | 关 | 只统计不落盘 | + +默认只取 `is_final = true`(产生客户端响应的最终尝试)且上游返回 200 的记录—— +中间失败尝试的段1 与最终尝试相同,段3 则是错误体,不是可用的响应语料。 + +### 先 dry-run + +```bash +team-api corpus-extract --dry-run --since 2026-09-11 +``` + +输出示例: + +``` +扫描 3271 条日志 → 47 种结构形状 → 61 个语料文件 + 跳过 12 条(缺 conversion 协议元数据,可能是非 relay 路径) + +转换方向覆盖: + openai→claude 18 + openai→gemini 14 + claude→openai 9 + gemini→openai 7 + responses→openai 13 +(方向覆盖不全时,补充对应渠道的调试采集再跑一次) + +[dry-run] 未写入任何文件 +``` + +**怎么读这个统计**: + +- `3271 条 → 47 种形状` 说明去重生效了(真实流量冗余极大,这个压缩比是正常的)。 + 如果形状数接近日志条数,说明指纹没起作用,需要排查。 +- **方向覆盖表是最重要的一行**。上例缺 `claude→gemini`、`responses→claude` 等跨原生方向, + 说明没有配置对应的渠道组合,需要补采。 +- `跳过(缺 conversion)` 少量正常(非 relay 路径的请求);如果占比很高, + 说明采到的多是非转换流量。 + +### 输出结构 + +``` +relaykit/relayconvert/register/golden/ +├── real_inputs/ # 段1:客户端请求语料 +│ ├── openai__image-tools_a1b2c3d4.json +│ ├── claude__basic_e5f6a7b8.json +│ └── ... +├── real_stream_inputs/ # 段3:上游流式响应(SSE / NDJSON 原文) +│ ├── claude__thinking-toolloop_c9d0e1f2.txt +│ └── ... +├── real_responses/ # 段3:上游非流式响应 +│ └── gemini__websearch_b3c4d5e6.json +└── corpus_manifest.json # 清单与统计 +``` + +文件名格式:`<格式>__<特征>_<结构指纹前8位>.<扩展名>`,与手写语料的 +`<格式>__<场景>` 约定保持形似,便于人工浏览归类。 + +> **为什么与手写语料分目录**:真实语料量大且会持续增长,混进 `inputs/` 会让精选集难以 +> 阅读;而且金样本套件对手写目录有「文件名必须匹配已知场景」的校验, +> 真实语料的自动命名会触发失败。 + +### 读懂 manifest + +`corpus_manifest.json` 记录每条语料的来源与统计: + +```json +{ + "generated_at": "2026-09-11T23:30:00+08:00", + "source": { "since": "2026-09-11", "limit": 5000 }, + "stats": { + "scanned": 3271, "skipped_no_conv": 12, "skipped_empty": 3, + "shapes": 47, "written": 61 + }, + "directions": { "openai→claude": 18, "openai→gemini": 14 }, + "entries": [ + { + "file": "real_inputs/openai__image-tools_a1b2c3d4.json", + "kind": "request", + "format": "openai", + "features": ["image", "tools"], + "shape_hash": "a1b2c3d4...", + "occurrences": 137, + "client_format": "openai", + "upstream_format": "claude", + "relay_mode": "chat_completions", + "is_stream": false, + "model_name": "gpt-4o", + "source_request_id": "...", + "source_log_id": 88123, + "captured_at": "2026-09-11T14:22:31+08:00" + } + ] +} +``` + +**`occurrences` 是最有价值的字段**:该结构形状在采集窗口内出现了多少次, +反映真实流量权重。挑选哪些语料提升进精选金样本时,优先看这个数—— +高频形状是线上主干路径,最值得锁死。 + +`source_log_id` / `source_request_id` 可回溯到 `chn_debug_logs` 原始记录。 + +--- + +## 5. 第三步:使用语料(**待实现**) + +> **当前状态**:工具链(采集 → 提取 → 脱敏 → 去重 → 落盘)已完成并有单元测试覆盖; +> **测试接线尚未实现**。本节是可直接照做的实施计划,续作从这里开始。 +> +> 前置条件:先按 §3 采一批语料、跑一次 `corpus-extract`,**看过真实语料长什么样** +> 再动手——语料的实际形状会影响下面几个断言的写法。 + +三项工作,按价值排序。建议顺序 (a) → (b) → (c)。 + +### (a) 不变量测试(主力价值,先做这个) + +**目标**:把能力守恒**从「一条手写满配请求」推广到「几百个真实形状」**。 +现有 `capExpectations`(`register/capability_invariant_test.go`)只对着一条人工构造的 +满配请求验证过,覆盖的是「写的人想得到的组合」。 + +**落点**:新建 `relaykit/relayconvert/register/realcorpus_test.go` + +**实现要点**: + +1. 枚举 `golden/real_inputs/*.json`,文件名前缀即客户端格式(`openai__...` → openai)。 + 用 `parseWireRequest`(该文件已有,与宿主 `parseInboundRequest` 同款裸 unmarshal)解析。 +2. 对每条语料 × 每个 `From` 匹配的已注册请求转换器,断言: + - 不 panic;转换失败**允许**(真实流量含畸形请求),但不得返回 `(nil, nil)` + - 成功时结果必须可 `json.Marshal`(宿主紧接着就要序列化发上游) +3. **能力守恒**:复用 `extractProfile` 提取输入与输出画像,逐维比对: + - 自定义工具名集合:输出必须是输入的超集(目标格式支持工具时) + - 图片数、工具循环轮次:不得减少 + - 目标格式表达不了的维度按 `capExpectations` 的既有缺口表跳过 +4. 流式同理:枚举 `golden/real_stream_inputs/*.txt`, + 复用 `goldensuite_stream_test.go` 的 `runStreamConverter`。 + +**注意**:真实语料的转换失败率不会是 0(畸形请求、罕见字段组合)。 +不要把「转换失败」当断言失败——**要断言的是「不崩溃」和「成功时不丢能力」**。 +如果失败率异常高(比如 >5%),那本身是个信号,值得逐条看。 + +**验收**:`go test -run TestRealCorpus ./relayconvert/register/` 通过, +且能打印出「N 条语料 × M 个方向,K 条转换失败」的统计。 + +### (b) 模糊测试种子 + +**目标**:真实形状作为变异起点,大幅提升 fuzz 的有效覆盖。 +目前 fuzz 从手写对抗种子出发,起点仍受限于想象力。 + +**落点**:`relaykit/relayconvert/register/fuzz_test.go` + +**实现**:`addSeedsFromDir` 机制现成,加目录即可: + +```go +addSeedsFromDir(f, filepath.Join("golden", "real_inputs"), ".json") +``` + +注意 `addSeedsFromDir` 按 `<格式>__<场景>` 解析文件名取格式前缀, +真实语料的命名(`<格式>__<特征>_<指纹>`)第一段同样是格式,可直接复用。 + +流式目标同理加 `real_stream_inputs`。 + +**验收**:`go test -run XXX -fuzz FuzzRequestConversion -fuzztime 60s ./relayconvert/register/` +无失败;`gathering baseline coverage` 的种子数应明显上升。 + +### (c) 精选金样本 + +**目标**:把最有代表性的真实形状锁进字节级金样本。 + +**做法**:按 manifest 的 `occurrences` 降序挑 **10–20 条**,重命名为 +`<格式>__<场景>.json` 放进手写语料目录 `golden/inputs/`, +在 `goldenScenarios`(`goldensuite_test.go`)登记新场景名, +然后 `go test -run TestGoldenSuite -update-golden ./relayconvert/register/` 生成 expected。 + +> ⚠️ **不要给全部真实语料生成字节级金样本**。几百个 expected 文件会让任何一次有意改动 +> 产生巨量 diff,而「审查变化面、确认改 A 方向没有波及 B 方向」正是金样本套件的核心价值, +> 那样就废了。这也是真实语料与手写语料**分目录**的原因。 + +### (d) 可选:生产一致性校验 + +段2/段4 目前未被使用(见 §6.4)。若要做,用法是**重构前采集、重构后回放**: +段1 → 当前转换器 → 与段2 比对。需要先给 `Record` 补段2/段4 字段, +并在 manifest 里记录渠道配置快照(系统提示注入、参数覆盖、模型映射都会造成合理差异)。 + +优先级低于 (a)(b)(c),等前面跑顺再说。 + +--- + +## 6. 工作原理 + +### 6.1 结构指纹去重 + +真实流量冗余极大——一万条记录往往只有几十种结构。直接落盘既臃肿又无益, +因此按**结构指纹**去重,每种形状只留代表样本。 + +指纹算法(`internal/utility/corpus/shape.go`)递归归一化 JSON: + +- **对象**:保留排序后的 key,值递归求形状 +- **数组**:逐元素求形状后**游程压缩**,连续相同形状折叠为 `{shape, n}`, + `n` 按 `1 / 2 / 3 / many` 分桶 +- **字符串**:判别式值(`type`、`role`、`finish_reason` 等,决定转换分支)保留原值; + 自由文本(`content`、`text`、`model`、`arguments` 等,见 `freeTextKeys`)折叠为 `#str` +- **数字 / 布尔 / null**:折叠为类型占位 + +**分桶的意义**:「1 条消息」与「2 条消息」是结构差异要区分; +「50 条」与「51 条」是长度噪音要折叠。 + +**流式另有一套**(`FingerprintStream`):逐帧取 `data` 负载的形状, +再对帧序列做游程压缩。不折叠的话,「一次回答 3 个 token」和「300 个 token」 +会被算作两种形状,去重完全失效。 + +两个按**值/键形态**兜底的折叠(key 名不可枚举,只能按形态识别): + +- **裸 UUID 字符串**折叠为 `#str`:客户端把会话标识塞进任意命名的字段 + (codex 的 `prompt_cache_key` / `session_id` / `thread_id` / `x-codex-*` 镜像等), + 值随会话变化——当枚举保留会让跨会话的同形状请求指纹互异。 +- **按 id 索引的对象 key**(`msg_`、32 位十六进制等)折叠进 `#id-keyed` 桶 + (保留条目数分桶与值形状):Responses 的 usage 明细按消息 id 建 map, + id 由服务端逐次生成——原样进指纹的话这类报文每次都是新形状。 + +> **重要**:指纹**只用于去重判等**——把自由文本折叠成 `#str` 不代表语料里就没有内容, +> 它只是让「内容不同、结构相同」的记录被认作同一形状。所以指纹可以折叠得很彻底。 +> +> 指纹在**脱敏之后**计算。脱敏保持结构不变,但会重排 key 顺序、归一化数值格式, +> 在原文上算指纹会让同形状的记录因序列化差异被判成不同形状、去重失效。 + +### 6.2 特征识别 + +用于文件命名归类,让人浏览目录时一眼看出语料覆盖了什么能力。 + +采用**跨格式的通用扫描**——遍历 JSON 的全部 key 与判别式字符串值, +对照标记表(`featureMarkers`)匹配,而非按格式写四套解析器。 +各协议表达同一能力的字段名不同但都在这张表里(例如 image 同时认 +`image_url` / `inlineData` / `input_image`),新增格式无需改代码。 + +识别的特征:`tools`、`toolloop`、`image`、`thinking`、`websearch`、`audio`、`file`、 +`multiturn`(消息数 > 2)。无特征时命名为 `basic`。 + +特征只影响文件名,识别偏差不影响语料本身。 + +### 6.3 体积控制 + +`--max-inline`(默认 8KB):超过阈值的 base64 串替换为 1×1 透明 PNG 的 base64。 + +此选项**仅在 `--no-scrub` 时生效**——脱敏开启时所有内联数据都会被换成占位图, +体积问题一并解决。 + +**这是体积控制,不是脱敏**。图片/音频的**内容**对协议转换毫无影响(转换器只搬运 +`mimeType` 与 `data` 字段),但一张截图的 base64 动辄数 MB,原样落盘会让语料仓库失控。 + +选择真实可解码的 PNG 而非任意字符串,是为了让图片处理路径(尺寸探测、格式校验) +拿到替身后仍能正常工作,语料不会因替身而走进异常分支。 + +### 6.4 段2/段4 为什么不用 + +段2(发给上游的请求)和段4(返回客户端的字节)是**当时那次转换的输出**, +不是输入。它们的用途是另一件事——**生产一致性校验**: + +段1 → 当前转换器 → 与段2 比对,差异即行为变化。 + +但**差异不等于 bug**:渠道配置(系统提示注入、参数覆盖、模型映射)和代码版本 +都会造成合理差异。正确用法是**重构前采集、重构后回放**,当作重构的回归网, +而且需要同时记录渠道配置快照才能可靠比对。 + +本工具目前不做这件事。 + +--- + +## 7. 注意事项 + +### 脱敏(默认开启) + +核心判断:**协议转换器只关心结构,不关心内容**。所以自由文本可以激进替换, +不损失任何测试价值。但「激进」必须止步于两类东西,否则语料会变成**假阳性的来源**—— +测试失败了,却不是代码的错,是语料自己坏了。 + +#### 会被替换的 + +| 类别 | 处理方式 | +|------|----------| +| 自由文本(`content`/`text`/`instructions` 等) | **等长**填充文本替换 | +| 工具参数(`arguments`/`args`/`input` 子树) | 递归替换值,**保持仍是合法 JSON** | +| 自定义工具名与描述 | 一致映射为 `tool_1`/`tool_2`…(`--keep-tool-names` 可保留) | +| 标识符(`id`/`call_id`/`tool_call_id`/`tool_use_id` 等) | 一致映射,**保留前缀**(`toolu_0001`) | +| 会话级标识符(`session_id`/`thread_id`/`prompt_cache_key`/`x-codex-*` 等) | 一致映射;**裸 UUID 形态的值无论挂在哪个 key 下都兜底映射**(key 名不可枚举,codex 曾把同一会话 UUID 塞进 7 个字段) | +| 内联 base64 数据 | 换成可解码的 1×1 PNG,保留 `data:;base64,` 前缀 | +| 远程 URL | 换成 `https://scrubbed.example/resource` | +| 思考签名、加密块 | 固定标记 | +| 凭证形态(`sk-`/`AIza`/`ghp_`/JWT 等) | 无条件替换,**不管出现在哪个字段** | + +#### 必须保留的(否则语料失效) + +**① 引用一致性** —— 这是最容易做错、也是后果最严重的一条: + +- OpenAI:`tool_calls[].id` ←→ `messages[].tool_call_id` +- Claude:`tool_use.id` ←→ `tool_result.tool_use_id` +- Responses:`function_call.call_id` ←→ `function_call_output.call_id` +- **Gemini 没有 id,`functionCall` ↔ `functionResponse` 靠函数名关联** —— + 所以工具名也必须一致映射 + +打断任何一条,工具循环链就断了。而工具循环恰是协议转换最容易出 bug 的地方, +语料坏掉会让测试报「工具循环丢失」,排查半天发现是语料的锅。 + +实现上是按原值建映射表、整条记录内全局一致替换;**同一记录的请求与响应共用同一张表**, +保证两个语料文件之间的引用也对得上。 + +**② 判别式与行为相关字段** —— 原样保留: + +- `type` / `role` / `finish_reason` / `stop_reason`:决定转换分支 +- `model`:决定模型相关分支(Claude 默认 `max_tokens` 按模型名分档、 + Vertex 按名字是否含 `claude` 选端点) +- 协议内置工具名(`web_search`、`code_execution`、`bash` 等): + 转换器按名识别服务端工具,改掉语料就不再触发那些能力分支 +- 数字与布尔(`max_tokens`、`temperature`、`usage`):影响转换且不含隐私 + +**③ 形态约束**: + +- tool 的 `arguments` 是**JSON 字符串**,替换后必须仍是合法 JSON—— + 否则转换器走进解析失败分支,语料反映的就不是线上行为 +- 自由文本按**字节长度**等长替换——token 估算(2 字符/token)、截断阈值、 + grounding 的字节偏移校正都与长度相关,长度塌缩会丢掉这些路径的覆盖 + +#### 已知代价 + +脱敏经过「解析 → 改写 → 重新序列化」,输出的 key 顺序与空白和原文不同。 +语料本就要被转换器重新解析,语义等价即可;但这意味着**脱敏后的语料不适合 +做字节级的 wire 保真校验**(重复 key、异常转义之类的原始痕迹会丢失)。 +需要那种校验时用 `--no-scrub` 单独采一批。 + +#### 仍需人工确认 + +脱敏是启发式的,不是保证。**提交前抽查几个文件**——尤其注意自定义字段: +落在未知 key 下、短且无空格的字符串会按「疑似枚举值」放行。 + +### 定期刷新 + +协议在演进(新字段、新能力),旧语料会逐渐偏离现实。建议在大版本升级或 +接入新客户端后重新采集一轮。 + +--- + +## 8. 故障排查 + +**查不到日志** + +命令会提示三个检查点:目标渠道是否已开启 `debug_log_enabled`、采集窗口内是否确有流量、 +`--since`/`--until`/`--channel` 是否过严。另外注意三个过滤器 +(tenant/user/api_key)是 AND 关系,配错会导致一条都录不上。 + +**形状数接近日志条数(去重失效)** + +说明指纹把本应相同的结构判成了不同。常见原因是某个自由文本字段没进 +`freeTextKeys`(`internal/utility/corpus/shape.go`)——比如新增的协议字段。 +把它加进表即可。 + +**方向覆盖不全** + +覆盖表缺某个方向,说明没有对应的(客户端格式 × 渠道协议)组合流量。 +跨原生方向(如 `claude→gemini`、`responses→claude`)尤其容易漏—— +需要专门配置一个 Gemini 渠道并用 Claude 格式去请求它。 + +**某类语料一条都没有** + +- `real_responses/` 为空:可能全部流量都是流式的(正常),或上游都没返回 200 +- `real_stream_inputs/` 为空:采集的全是非流式请求 +- 全部为空但 `scanned` 不为 0:检查 `skipped_no_conv` 是否很高, + 说明采到的是非 relay 路径流量 + +--- + +## 9. 代码位置 + +| 路径 | 职责 | +|------|------| +| `internal/cmd/cmd_corpus_extract.go` | CLI 命令:参数解析、数据库查询、报文解码 | +| `internal/utility/corpus/shape.go` | 结构指纹(含流式) | +| `internal/utility/corpus/sample.go` | 特征识别、样本构建、manifest 结构 | +| `internal/utility/corpus/collect.go` | 去重收集、落盘 | +| `internal/utility/corpus/scrub.go` | 脱敏:一致映射、字段分类、等长替换 | +| `internal/utility/corpus/shrink.go` | 内联数据体积控制(`--no-scrub` 路径) | +| `internal/utility/corpus/corpus_test.go` | 指纹/特征/去重单元测试 | +| `internal/utility/corpus/scrub_test.go` | 脱敏单元测试(重点:引用一致性) | + +> 纯逻辑放在 `internal/utility/` 而非 `internal/logic/`,是因为后者会被 +> `gf gen service` 扫描并产生噪音文件(`internal/dispatchadapter` 当年正因此被移出 +> `internal/logic`)。 + +**扩展点**: + +- 新增协议格式 → 在 `featureMarkers` 补标记词;如有新的自由文本字段, + 同步加进 `freeTextKeys` +- 调整去重粒度 → 改 `bucketCount` 的分桶策略或 `freeTextKeys` 的范围 +- 新增语料类别 → 加 `SampleKind` 并实现 `dirOf()` / `ext()` +- **新增协议字段时检查脱敏分类**(`scrub.go`):承载标识符的加进 `idKeys` + (否则引用会断)、影响转换分支的加进 `behaviorKeys`(否则语料不再触发该分支)、 + 承载用户数据的加进 `userDataKeys`(否则可能漏脱敏) + +--- + +## 10. 当前进度与续作指引 + +> 本章是「下次接着做」的入口。改动本功能后请同步更新。 + +### 已完成(2026-09-11) + +工具链全部打通,全仓测试通过: + +- `corpus-extract` CLI 命令,已注册进 `main`(`team-api corpus-extract -h` 可见) +- 结构指纹去重(含流式游程压缩与计数分桶) +- 跨格式特征识别与文件命名 +- 脱敏(默认开启):一致映射、字段分类、等长替换、凭证兜底 +- manifest 生成(含 `occurrences` 流量权重) +- 单元测试:`corpus_test.go`(指纹/特征/去重)+ `scrub_test.go`(脱敏,重点是引用一致性) + +### 首轮真实提取(2026-09-12) + +用 Claude Code(claude 入站)+ codex(responses 入站)采集了 200+ 条调试日志, +正式提取出 236 个语料文件(claude→responses 80 / claude→gemini 25 / claude→openai 17 / +responses→responses 36 / claude→claude 78)。过程中发现并修复了四个工具链问题: + +1. **会话标识符泄漏**:codex 把同一会话 UUID 塞进 `prompt_cache_key` / `session_id` / + `thread_id` / `turn_id` / `root_turn_id` / `x-codex-window-id` / `x-codex-installation-id` + 七个字段,key 名不可枚举——脱敏与指纹都改为按裸 UUID **值形态**兜底(`scrub.go` ①、 + `shape.go` isEnumLike),跨会话去重不再碎片化。 +2. **usage 明细的 id-keyed map 撕裂去重**:Responses 上游按消息 id 建 usage map, + 指纹把对象 key 原样保留导致这类流永不判等——`shapeOf` 将 id 形态的 key 折叠进 + `#id-keyed` 桶(保留数量分桶与值形状)。 +3. **重复提取产生孤儿文件**:脱敏/指纹语义变化会改文件名,旧文件残留且被目录枚举型 + 测试误读——`Write` 现在每次先清空三个 `real_*` 目录再重建。 +4. **CLI 摘要与 Brief 谎报「未脱敏」**:修正为按实际状态提示。 + +同一批语料还直接定位并修复了第一个线上 bug:**Gemini 工具 schema 对 `prefixItems` +元组语法的 400**(见 §1),回归测试用的就是语料里的真实 schema。 + +**剩余缺口**(对照 §3 的矩阵):openai / gemini 入站完全缺失、全部方向的非流式为 0、 +`real_responses/` 为空、image / audio / file 特征为 0、ollama 上游未接。 + +### 第二轮采集与修复(2026-09-12,codex 补充语料) + +补充了 codex 打 claude / deepseek / gemini 模型的流量(424 条日志 → 488 个语料), +据此定位并修复了两个转换层 bug、一个语料工具 bug: + +1. **DeepSeek thinking 模式 400**(`reasoning_content must be passed back`): + codex 多轮历史里的 reasoning 项被 responses→chat 转换直接丢弃。修复为回传—— + 明文思考文本(summary / content 两种形态)挂到下一条 assistant 消息的 + `reasoning_content`(relaykit 与 relay/channel/openai 两处镜像同步); + 同时 chat→responses 流式合成器补齐 reasoning 输出项(此前只发孤儿 delta, + 客户端无法在下一轮回传,纯 chat 链路的多轮会话必 400)。 +2. **短中文脱敏泄漏**:中文不含空格,「短且无空格即枚举」的启发式对 CJK 失效, + 用户输入与思考内容原样落进语料。修复:值含 CJK 表意字符一律按自由文本处理 + (脱敏等长替换 + 指纹折叠)。 +3. **乱码排查(未定位到网关侧成因)**:codex→gpt(responses 直连)流式中文乱码。 + 全链路审计结论:上游字节(段3)全部合法 UTF-8、delta 无 rune 截断、内容语义正常; + 网关直连路径逐行字节透传(tee/帧化/保活均不改写字节)。**下次复现时保持调试开启**, + 段4(客户端最终收到的字节)会揭示污染发生在哪一段。 + +### 待做 + +| 优先级 | 事项 | 说明 | +|--------|------|------| +| 1 | **采一批真实语料** | 前置条件。按 §3 的打法采集,跑 `--dry-run` 看方向覆盖 | +| 2 | **(a) 不变量测试** | 见 §5(a),落点 `register/realcorpus_test.go`。主力价值 | +| 3 | **(b) fuzz 种子** | 见 §5(b),改一行即可 | +| 4 | **(c) 精选金样本** | 见 §5(c),按 `occurrences` 挑 10–20 条 | +| 5 | (d) 生产一致性校验 | 见 §5(d),可选,优先级最低 | + +**下一步具体动作**:开渠道调试 → 按 §3 采集 → `corpus-extract --dry-run` → +确认方向覆盖 → 正式提取 → 抽查语料 → 开始 §5(a)。 + +### 已定决策(不必重新讨论) + +这些是实现时权衡过的,改动前先看理由: + +| 决策 | 理由 | +|------|------| +| 做成 CLI 而非管理端接口 | 四段报文含请求体原文,不该经 HTTP 出网 | +| 纯逻辑放 `internal/utility/` 而非 `internal/logic/` | 后者会被 `gf gen service` 扫描产生噪音文件 | +| 真实语料与手写语料**分目录** | 真实语料量大且增长;手写目录有「文件名必须匹配已知场景」的校验 | +| 只用段1/段3,不用段2/段4 | 段2/4 是转换的**输出**不是输入;用它们是另一种玩法(§6.4) | +| 指纹在**脱敏之后**计算 | 脱敏会重排 key 顺序,在原文上算会导致去重失效 | +| 脱敏默认**开启** | 安全默认;`--no-scrub` 显式选择原文 | +| 工具名默认**替换**、内置名保留 | 自定义名可能泄漏业务逻辑;内置名是转换器的识别依据 | +| `model` 字段**保留原值** | 决定 Claude 默认 max_tokens 分档与 Vertex 端点选择 | +| 不给全部真实语料生成金样本 | 会让「审查变化面」的能力失效(§5(c)) | + +### 踩过的坑 + +- **引用一致性**是脱敏最容易做错的地方,且后果隐蔽:语料坏掉会让测试报 + 「工具循环丢失」,排查半天才发现是语料的锅。四种协议的关联方式各不相同, + Gemini 甚至没有 id、靠函数名关联(§7)。 +- **流式指纹必须折叠帧数**。不折叠的话每条流都是独一无二的形状,去重完全失效。 +- **tool `arguments` 是 JSON 字符串**,脱敏后必须仍是合法 JSON, + 否则转换器走进解析失败分支,语料反映的不是线上行为。 +- **脱敏后的语料不适合做字节级 wire 保真校验**(重新序列化丢失了 key 顺序等原始痕迹), + 需要时用 `--no-scrub` 单独采一批。 +- **启发式要考虑 CJK**:「无空格即枚举」「值形态」这类判断对中文全部失效过一遍—— + 短中文被当枚举放行(泄漏)、会话 UUID 要按值形态兜底(key 不可枚举)、 + usage 明细按 id 建 map(对象 key 也会撕裂去重)。凡是按字符串形态做判断的地方, + 先想一遍 CJK 和动态标识符两个反例。 + +### 相关文档 + +- `docs/reference/api-format-reference.md` —— 各协议的 JSON / SSE 格式参考 +- `docs/Claude-Code请求问题排查记录.md` —— 首轮采集(Claude Code 流量)暴露的问题与修复 +- `docs/codex请求问题排查记录.md` —— 第二轮采集(codex 流量)暴露的问题与修复 +- `relaykit/relayconvert/register/` —— 六层测试防线的实现(金样本、能力守恒、 + 矩阵守卫、模糊测试) diff --git a/internal/cmd/cmd_corpus_extract.go b/internal/cmd/cmd_corpus_extract.go new file mode 100644 index 0000000..19ee0fa --- /dev/null +++ b/internal/cmd/cmd_corpus_extract.go @@ -0,0 +1,439 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/os/gcmd" + "github.com/gogf/gf/v2/os/gtime" + + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/utility/corpus" +) + +// corpus-extract:从 chn_debug_logs 提取协议转换测试语料。 +// +// 为什么做成 CLI 而不是管理端接口:调试日志是四段完整报文(含请求体原文), +// 不该经 HTTP 出网;CLI 走应用自身配置连库,查询、去重、落盘一次完成。 +// +// 默认对报文体做脱敏:自由文本等长替换、内联数据换占位图,同时**保持引用一致性** +// (tool_call_id / 工具名的映射在一条记录内前后一致)与判别式字段原值。 +// --no-scrub 可关闭,但那样落盘的是报文原文,仅限确知无敏感数据的测试流量。 + +const ( + // defaultCorpusOutput 语料输出根目录:**仓库外的独立目录**(语料体积由结构决定、 + // 动辄数百 MB,不进 team-api 仓库)。可用环境变量 TEAM_API_CORPUS_DIR 覆盖, + // 下游测试按同一变量发现语料(缺失即跳过)。 + defaultCorpusOutput = "../team-api-corpus" + defaultScanLimit = 5000 + defaultMaxInline = 8192 + defaultMaxText = 16384 +) + +var corpusExtractCmd = gcmd.Command{ + Name: "corpus-extract", + Usage: "corpus-extract [-o <目录>] [--since <时间>] [--channel ] [--limit N]", + Brief: "从渠道调试日志提取协议转换测试语料(默认脱敏)", + Arguments: []gcmd.Argument{ + {Name: "output", Short: "o", Brief: "语料输出根目录(默认 " + defaultCorpusOutput + ")"}, + {Name: "since", Brief: "起始时间,如 2026-09-01 或 2026-09-01T10:00:00Z"}, + {Name: "until", Brief: "结束时间,同上"}, + {Name: "channel", Brief: "渠道 ID 过滤,逗号分隔"}, + {Name: "format", Brief: "客户端格式过滤:openai / claude / gemini / responses"}, + {Name: "limit", Brief: fmt.Sprintf("最多扫描日志行数(默认 %d)", defaultScanLimit)}, + {Name: "per-shape", Brief: "每种结构形状保留的样本数(默认 1)"}, + {Name: "max-text", Brief: fmt.Sprintf("单个字符串值的脱敏填充长度上限(默认 %d,0=等长不截断)", defaultMaxText)}, + {Name: "max-inline", Brief: fmt.Sprintf("内联 base64 数据超过该字节数则替换为占位,控制语料体积(默认 %d,0=不处理)", defaultMaxInline)}, + {Name: "no-scrub", Brief: "关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量)", Orphan: true}, + {Name: "keep-tool-names", Brief: "脱敏时保留自定义工具名原值(内置工具名始终保留)", Orphan: true}, + {Name: "include-errors", Brief: "包含失败尝试(默认只取成功的最终尝试)", Orphan: true}, + {Name: "dump-segments", Brief: "打印指定日志 ID(逗号分隔)的段1/段2报文片段,用于诊断"}, + {Name: "dry-run", Brief: "只统计不落盘", Orphan: true}, + }, + Func: runCorpusExtract, +} + +func runCorpusExtract(ctx context.Context, parser *gcmd.Parser) error { + output := corpusOutputDir(parser) + limit := parser.GetOpt("limit", defaultScanLimit).Int() + perShape := parser.GetOpt("per-shape", 1).Int() + maxInline := parser.GetOpt("max-inline", defaultMaxInline).Int() + noScrub := parser.GetOpt("no-scrub") != nil + keepToolNames := parser.GetOpt("keep-tool-names") != nil + includeErrors := parser.GetOpt("include-errors") != nil + dryRun := parser.GetOpt("dry-run") != nil + maxText := parser.GetOpt("max-text", fmt.Sprintf("%d", defaultMaxText)).Int() + channelFilter := parser.GetOpt("channel").String() + formatFilter := parser.GetOpt("format").String() + since := parser.GetOpt("since").String() + until := parser.GetOpt("until").String() + + rows, err := queryDebugLogs(ctx, debugLogQuery{ + since: since, + until: until, + channels: channelFilter, + format: formatFilter, + limit: limit, + includeErrors: includeErrors, + }) + if err != nil { + return err + } + if len(rows) == 0 { + fmt.Println("未查询到符合条件的调试日志。请确认:") + fmt.Println(" 1) 目标渠道已开启 debug_log_enabled;") + fmt.Println(" 2) 采集窗口内确有流量;") + fmt.Println(" 3) --since / --until / --channel 过滤条件是否过严。") + return nil + } + + scrubOpts := corpus.ScrubOptions{Enabled: !noScrub, KeepToolNames: keepToolNames, MaxTextLen: maxText} + collector := corpus.NewCollector(perShape, scrubOpts) + for _, row := range rows { + collector.Add(row.toRecord(maxInline)) + } + + if includeErrors { + printFailedAttempts(rows) + } + + if ids := splitIDs(parser.GetOpt("dump-segments").String()); len(ids) > 0 { + printSegments(rows, ids) + } + + samples := collector.Samples() + stats, err := corpus.Write(samples, collector.Stats(), corpus.WriteOptions{ + OutputDir: output, + DryRun: dryRun, + Scrubbed: !noScrub, + Source: corpus.ManifestSource{ + Since: since, Until: until, + Channel: channelFilter, Format: formatFilter, + Limit: limit, + }, + }) + if err != nil { + return err + } + + printCorpusSummary(output, stats, samples, dryRun, !noScrub) + return nil +} + +// corpusOutputDir 语料输出目录:命令行 -o > 环境变量 TEAM_API_CORPUS_DIR > 默认仓库外目录。 +func corpusOutputDir(parser *gcmd.Parser) string { + if v := parser.GetOpt("output").String(); v != "" { + return v + } + if v := os.Getenv("TEAM_API_CORPUS_DIR"); v != "" { + return v + } + return defaultCorpusOutput +} + +// printFailedAttempts 打印采集窗口内全部请求的时间线,失败尝试附上游状态、 +// 错误信息与响应体片段(--include-errors 时)。段3 错误体按设计不进语料 +// (不可作为响应语料),但它是排查「客户端没反应」类问题的关键证据。 +func printFailedAttempts(rows []debugLogRow) { + for _, r := range rows { + created := "" + if r.CreatedAt != nil { + created = r.CreatedAt.Time.Format("15:04:05") + } + fmt.Printf("%s log=%-4d %-18s ch=%-2d retry=%d us=%-3d cs=%-3d stream=%-5v %s→%s\n", + created, r.Id, r.ModelName, r.ChannelType, r.RetryIndex, + r.UpstreamStatusCode, r.ClientStatusCode, r.IsStream, + convClient(r.Conversion), convUpstream(r.Conversion)) + if r.Error != "" { + fmt.Printf(" error: %s\n", truncateStr(r.Error, 300)) + } + if r.UpstreamStatusCode != 200 && r.UpstreamStatusCode != 0 { + body := decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding) + fmt.Printf(" 段3: %s\n", truncateStr(string(body), 400)) + } + } +} + +func truncateStr(s string, n int) string { + if len(s) > n { + return s[:n] + "..." + } + return s +} + +// printSegments 打印指定日志的段2(发往上游的最终请求)结构化摘要,诊断转换结果用。 +// 只打字段级信息不打原文:报文体动辄数十 KB(codex 系统提示),原文会淹没真正要看的参数区。 +func printSegments(rows []debugLogRow, ids []string) { + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + for _, r := range rows { + if !wanted[fmt.Sprintf("%d", r.Id)] { + continue + } + fmt.Printf("===== log=%d %s %s→%s us=%d =====\n", r.Id, r.ModelName, + convClient(r.Conversion), convUpstream(r.Conversion), r.UpstreamStatusCode) + body := decodeBody(r.UpstreamReqBody, r.UpstreamReqEncoding) + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + fmt.Printf(" 段2 非 JSON(%v): %s\n", err, truncateStr(string(body), 200)) + continue + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Printf(" 顶层字段: %s\n", strings.Join(keys, ", ")) + for _, k := range []string{"model", "max_tokens", "max_completion_tokens", "reasoning_effort", "temperature", "top_p", "stream"} { + if v, ok := m[k]; ok { + fmt.Printf(" %s: %v\n", k, v) + } + } + if v, ok := m["web_search_options"]; ok { + fmt.Printf(" web_search_options: %s\n", truncateStr(fmt.Sprint(v), 200)) + } else { + fmt.Println(" web_search_options: <无>") + } + if tools, ok := m["tools"].([]any); ok { + types := map[string]int{} + for _, t := range tools { + if tm, ok := t.(map[string]any); ok { + if ty, _ := tm["type"].(string); ty != "" { + types[ty]++ + } + } + } + fmt.Printf(" tools: %d 个 %v\n", len(tools), types) + } else { + fmt.Println(" tools: <无>") + } + if msgs, ok := m["messages"].([]any); ok { + roles := map[string]int{} + for _, msg := range msgs { + if mm, ok := msg.(map[string]any); ok { + if r, _ := mm["role"].(string); r != "" { + roles[r]++ + } + } + } + fmt.Printf(" messages: %d 条 %v\n", len(msgs), roles) + } + } +} + +func convClient(conversion string) string { + var c struct { + ClientFormat string `json:"client_format"` + } + _ = json.Unmarshal([]byte(conversion), &c) + return c.ClientFormat +} + +func convUpstream(conversion string) string { + var c struct { + UpstreamFormat string `json:"upstream_format"` + } + _ = json.Unmarshal([]byte(conversion), &c) + return c.UpstreamFormat +} + +func printCorpusSummary(output string, stats corpus.ManifestStats, samples []corpus.Sample, dryRun, scrubbed bool) { + fmt.Printf("\n扫描 %d 条日志 → %d 种结构形状 → %d 个语料文件\n", + stats.Scanned, stats.Shapes, stats.Written) + if stats.SkippedNoConv > 0 { + fmt.Printf(" 跳过 %d 条(缺 conversion 协议元数据,可能是非 relay 路径)\n", stats.SkippedNoConv) + } + if stats.SkippedEmpty > 0 { + fmt.Printf(" 跳过 %d 条(报文体为空或非 JSON)\n", stats.SkippedEmpty) + } + + byDir := map[string]int{} + for _, s := range samples { + if s.Meta.ClientFormat != "" && s.Meta.UpstreamFormat != "" { + byDir[s.Meta.ClientFormat+"→"+s.Meta.UpstreamFormat]++ + } + } + if len(byDir) > 0 { + fmt.Println("\n转换方向覆盖:") + for dir, n := range byDir { + fmt.Printf(" %-28s %d\n", dir, n) + } + fmt.Println("(方向覆盖不全时,补充对应渠道的调试采集再跑一次)") + } + + if dryRun { + fmt.Println("\n[dry-run] 未写入任何文件") + return + } + fmt.Printf("\n语料已写入 %s/{real_inputs,real_stream_inputs,real_responses}\n", output) + fmt.Printf("清单:%s/corpus_manifest.json\n", output) + if scrubbed { + fmt.Println("\n语料已脱敏(自由文本等长替换、标识符一致映射、内联数据换占位图);" + + "脱敏是启发式的,提交前建议抽查确认。") + } else { + fmt.Println("\n⚠️ 语料为报文原文、未做脱敏,提交前请确认其中不含真实业务数据。") + } +} + +// ---------- 数据库查询 ---------- + +type debugLogQuery struct { + since, until string + channels string + format string + limit int + includeErrors bool +} + +// debugLogRow 与 chn_debug_logs 查询字段对应。 +// (dump-segments 诊断需要段2 发往上游的最终请求体) +type debugLogRow struct { + Id int64 `orm:"id"` + RequestId string `orm:"request_id"` + ChannelType int `orm:"channel_type"` + ModelName string `orm:"model_name"` + UpstreamModel string `orm:"upstream_model"` + RelayMode string `orm:"relay_mode"` + InboundPath string `orm:"inbound_path"` + IsStream bool `orm:"is_stream"` + RetryIndex int `orm:"retry_index"` + UpstreamStatusCode int `orm:"upstream_status_code"` + ClientStatusCode int `orm:"client_status_code"` + Error string `orm:"error"` + ClientReqBody string `orm:"client_req_body"` + ClientReqEncoding string `orm:"client_req_encoding"` + UpstreamReqBody string `orm:"upstream_req_body"` + UpstreamReqEncoding string `orm:"upstream_req_encoding"` + UpstreamRespBody string `orm:"upstream_resp_body"` + UpstreamRespEncoding string `orm:"upstream_resp_encoding"` + Conversion string `orm:"conversion"` + CreatedAt *gtime.Time `orm:"created_at"` +} + +func queryDebugLogs(ctx context.Context, q debugLogQuery) ([]debugLogRow, error) { + m := dao.ChnDebugLogs.Ctx(ctx). + Fields("id,request_id,channel_type,model_name,upstream_model,relay_mode,inbound_path,"+ + "is_stream,retry_index,upstream_status_code,client_status_code,error,"+ + "client_req_body,client_req_encoding,upstream_req_body,upstream_req_encoding,"+ + "upstream_resp_body,upstream_resp_encoding,conversion,created_at"). + // 只取最终尝试:中间失败尝试的段1 与最终尝试相同,段3 多为错误体 + Where("is_final", true). + OrderDesc("id") + + if !q.includeErrors { + // 只取上游成功的尝试:失败尝试的段3 是错误体,不是可用的响应语料 + m = m.Where("upstream_status_code", 200) + } + if q.since != "" { + t, err := parseTimeFlag(q.since) + if err != nil { + return nil, fmt.Errorf("--since 时间格式无法解析: %w", err) + } + m = m.WhereGTE("created_at", t) + } + if q.until != "" { + t, err := parseTimeFlag(q.until) + if err != nil { + return nil, fmt.Errorf("--until 时间格式无法解析: %w", err) + } + m = m.WhereLTE("created_at", t) + } + if ids := splitIDs(q.channels); len(ids) > 0 { + m = m.WhereIn("channel_id", ids) + } + if q.format != "" { + // conversion 是 JSONB,按 client_format 过滤 + m = m.Where("conversion->>'client_format' = ?", q.format) + } + if q.limit > 0 { + m = m.Limit(q.limit) + } + + var rows []debugLogRow + if err := m.Scan(&rows); err != nil { + return nil, fmt.Errorf("查询调试日志失败: %w", err) + } + return rows, nil +} + +// toRecord 解码报文体并解析协议元数据。 +// maxInline > 0 时把超长的 base64 内联数据替换为占位,避免单条语料膨胀到数 MB。 +func (r debugLogRow) toRecord(maxInline int) corpus.Record { + rec := corpus.Record{ + ID: r.Id, + RequestID: r.RequestId, + ChannelType: r.ChannelType, + ModelName: r.ModelName, + UpstreamModel: r.UpstreamModel, + RelayMode: r.RelayMode, + InboundPath: r.InboundPath, + IsStream: r.IsStream, + UpstreamStatus: r.UpstreamStatusCode, + + ClientReqBody: decodeBody(r.ClientReqBody, r.ClientReqEncoding), + UpstreamRespBody: decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding), + } + if r.CreatedAt != nil { + rec.CapturedAt = r.CreatedAt.Time + } + if r.Conversion != "" { + _ = json.Unmarshal([]byte(r.Conversion), &rec.Conversion) + } + if maxInline > 0 { + rec.ClientReqBody = corpus.ShrinkInlineData(rec.ClientReqBody, maxInline) + rec.UpstreamRespBody = corpus.ShrinkInlineData(rec.UpstreamRespBody, maxInline) + } + return rec +} + +// decodeBody 按 encoding 列还原原始字节(plain 直取、base64 解码)。 +func decodeBody(body, encoding string) []byte { + if body == "" { + return nil + } + if encoding == "base64" { + if raw, err := base64.StdEncoding.DecodeString(body); err == nil { + return raw + } + return nil + } + return []byte(body) +} + +func parseTimeFlag(s string) (*gtime.Time, error) { + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + // 无时区的格式按本地时区解释(time.Parse 会按 UTC,造成「白天的时间被当成晚上」 + // 而查不到日志);RFC3339 自带时区偏移,Parse 与 ParseInLocation 行为一致 + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return gtime.NewFromTime(t), nil + } + } + return nil, fmt.Errorf("支持的格式:2006-01-02 / 2006-01-02 15:04:05 / RFC3339,实际收到 %q", s) +} + +func splitIDs(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +func init() { + if err := Main.AddCommand(&corpusExtractCmd); err != nil { + panic(err) + } +} diff --git a/internal/dispatchadapter/state_redis.go b/internal/dispatchadapter/state_redis.go index 64a6dea..8746f45 100644 --- a/internal/dispatchadapter/state_redis.go +++ b/internal/dispatchadapter/state_redis.go @@ -328,12 +328,18 @@ func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) { if o.Success { success = "1" } - // 健康观察成功即静默:本函数消费每个请求的结果事件,任何逐请求日志都会在生产流量下 - // 淹没日志;健康分轨迹排查走维护轮落盘日志 + ReadRuntime 直接读值。 - if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey, - success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil { - s.local.observe(o) // 降级:实例本地健康镜像 - return + // 探测失败不衰减健康 EWMA:cron 自动探测每 5 分钟一次,若持续失败按 0.93^N 衰减会把 + // 无真实流量的渠道健康分指数级拖垮,跟真实故障没有区别地互相污染。探测成功仍照常 + // 记录(回升健康 + 采样延迟),只跳过失败这一侧;熔断窗口不受影响,下方正常计数。 + skipHealthObserve := o.Probe && !o.Success + if !skipHealthObserve { + // 健康观察成功即静默:本函数消费每个请求的结果事件,任何逐请求日志都会在生产流量下 + // 淹没日志;健康分轨迹排查走维护轮落盘日志 + ReadRuntime 直接读值。 + if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey, + success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil { + s.local.observe(o) // 降级:实例本地健康镜像 + return + } } // 429 → softLimit 自动估计器(基线方案 §8.2) diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index d2f9548..a98de1c 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -288,10 +288,19 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (* baseURL = defaultProviderURL(req.Type) } - // Build settings JSON + // Build settings JSON(只写显式开启的布尔项,其余字段用 Parse 时的默认值) settingsJSON := "{}" - if req.UseProxy { - settingsJSON = `{"use_proxy":true}` + if req.UseProxy || req.WebSearchToGoogleSearch { + boolFlags := map[string]bool{} + if req.UseProxy { + boolFlags["use_proxy"] = true + } + if req.WebSearchToGoogleSearch { + boolFlags["web_search_to_google_search"] = true + } + if b, err := json.Marshal(boolFlags); err == nil { + settingsJSON = string(b) + } } tier := req.Tier @@ -411,8 +420,8 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* // Update use_proxy / debug_log_* in settings JSONB。 // 必须单块合并处理(读一次旧值 → 同时应用全部字段 → 回写一次): // 若写成多个同构块,各自从 DB 读旧值再各自 marshal,同请求多字段时会互相覆盖。 - if req.UseProxy != nil || req.DebugLogEnabled != nil || req.DebugLogTenantID != nil || - req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil { + if req.UseProxy != nil || req.WebSearchToGoogleSearch != nil || req.DebugLogEnabled != nil || + req.DebugLogTenantID != nil || req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil { // 注意:读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列, // 传入 *string 会直接报错;此前用 _ = Scan(&string) 吞错导致永远读到空串, // 每次局部更新都会把未提交的 settings 字段静默重置为默认值 @@ -424,6 +433,9 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* if req.UseProxy != nil { settings.UseProxy = *req.UseProxy } + if req.WebSearchToGoogleSearch != nil { + settings.WebSearchToGoogleSearch = *req.WebSearchToGoogleSearch + } if req.DebugLogEnabled != nil { settings.DebugLogEnabled = *req.DebugLogEnabled } @@ -590,6 +602,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq) Remark: ch.Remark, IsVIP: ch.IsVIP, UseProxy: settings.UseProxy, + WebSearchToGoogleSearch: settings.WebSearchToGoogleSearch, DebugLogEnabled: settings.DebugLogEnabled, DebugLogTenantID: settings.DebugLogTenantID, DebugLogUserID: settings.DebugLogUserID, @@ -914,11 +927,7 @@ var defaultProviderURLs = map[int]string{ 28: "http://localhost:9997", // MiniMax 国内站(海外站为 api.minimaxi.chat),adaptor 自拼 /v1/text/chatcompletion_v2 29: "https://api.minimax.chat", - // Coze 国内站(海外站为 api.coze.com),adaptor 自拼 /v3/chat - 32: "https://api.coze.cn", - // Dify 云端默认(api.dify.ai/v1/chat-messages);自部署实例需自行填写。 - // 填裸域名即可,adaptor 自拼 /v1/chat-messages,带 /v1 会双拼 - 33: "https://api.dify.ai", + // 32 = Coze、33 = Dify:渠道已移除,编号永久保留不复用 // 即梦:适配器拼 /v2/images/generations,无官方对齐端点可验证,需自行填写 34: "", // Codex 直连 chatgpt.com/backend-api/codex/responses(对齐 Codex CLI) diff --git a/internal/logic/admin/role_test.go b/internal/logic/admin/role_test.go index f6463e4..3d56a8d 100644 --- a/internal/logic/admin/role_test.go +++ b/internal/logic/admin/role_test.go @@ -223,7 +223,7 @@ func TestBuiltinRoleBoundaries(t *testing.T) { // 两处各存一份权限清单是必要的(迁移是 SQL、恢复默认是 Go),但漂移后果隐蔽: // 新装部署拿到迁移的版本,点了「恢复默认」却变成 Go 的版本,同一个角色在不同部署里权限不同。 func TestBuiltinRoleDefaultsMatchMigration(t *testing.T) { - raw, err := os.ReadFile("../../../migrations/000020_admin_role_management.sql") + raw, err := os.ReadFile("../../../migrations/000020_v0_2_17_version.sql") if err != nil { t.Fatalf("读取迁移脚本失败: %v", err) } diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index d7aea64..61b95f6 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -266,12 +266,14 @@ func GenerateBillingSummary(ctx context.Context, snapshot *BillingSnapshot) stri multDesc += fmt.Sprintf(" × 时段乘数(%.2f)", effTime) } - // 展开格式:(abc + def + feg) × 倍率 = 总计 + // 展开格式:(abc + def + feg) × 倍率 = 总计;无逐项明细时按实际费用反推展示基数(仅用于展示,不影响实际计费) costsExpr := "" if len(costParts) > 1 { costsExpr = fmt.Sprintf("(%s)", joinWithPlus(costParts)) } else if len(costParts) == 1 { costsExpr = costParts[0] + } else if snapshot.Settlement.ActualCost > 0 { + costsExpr = money(snapshot.Settlement.ActualCost / (effTenant * effTime)) } if costsExpr != "" { diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index f0ef7e2..61ac69c 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -491,7 +491,7 @@ func TestGenerateBillingSummary_TimeRule(t *testing.T) { if !strings.Contains(text, "闲时") { t.Errorf("summary should contain time rule name, got:\n%s", text) } - if !strings.Contains(text, "时段(0.50)") { + if !strings.Contains(text, "时段乘数(0.50)") { t.Errorf("summary should contain time multiplier in subtotal line, got:\n%s", text) } } diff --git a/internal/logic/monitor/relaykit_metrics_test.go b/internal/logic/monitor/relaykit_metrics_test.go index eab1d95..d9837a3 100644 --- a/internal/logic/monitor/relaykit_metrics_test.go +++ b/internal/logic/monitor/relaykit_metrics_test.go @@ -123,7 +123,7 @@ func TestTrackConverterCall_LongErrorTruncated(t *testing.T) { // 超过 500 字符的错误信息应被截断,避免撑爆 JSONB payload(覆盖 setLastError 截断分支)。 resetTracker() longMsg := strings.Repeat("x", 600) - TrackConverterCall("c_long", "openai", "coze", time.Millisecond, errors.New(longMsg)) + TrackConverterCall("c_long", "openai", "ollama", time.Millisecond, errors.New(longMsg)) got := GetRelaykitConverterMetrics() var found bool diff --git a/internal/utility/corpus/collect.go b/internal/utility/corpus/collect.go new file mode 100644 index 0000000..32ce85c --- /dev/null +++ b/internal/utility/corpus/collect.go @@ -0,0 +1,177 @@ +package corpus + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// Collector 按结构指纹聚合样本:同形状只保留前 perShape 条代表, +// 并统计该形状的出现次数(反映真实流量权重,写进 manifest)。 +type Collector struct { + perShape int + scrub ScrubOptions + + // key = kind + "/" + shapeHash + kept map[string][]Sample + counts map[string]int + order []string // 首次出现顺序,保证输出稳定 + + stats ManifestStats +} + +// NewCollector 创建收集器。perShape < 1 时按 1 处理。 +// scrub 为脱敏选项,逐记录新建 Scrubber(映射表按记录隔离)。 +func NewCollector(perShape int, scrub ScrubOptions) *Collector { + if perShape < 1 { + perShape = 1 + } + return &Collector{ + perShape: perShape, + scrub: scrub, + kept: make(map[string][]Sample), + counts: make(map[string]int), + } +} + +// Add 处理一条调试日志。返回本条产生的新增样本数(已达 perShape 上限时为 0)。 +func (c *Collector) Add(r Record) int { + c.stats.Scanned++ + + if r.Conversion.ClientFormat == "" && r.Conversion.UpstreamFormat == "" { + c.stats.SkippedNoConv++ + return 0 + } + samples := Build(r, NewScrubber(c.scrub)) + if len(samples) == 0 { + c.stats.SkippedEmpty++ + return 0 + } + + added := 0 + for _, s := range samples { + key := string(s.Kind) + "/" + s.ShapeHash + if _, exists := c.counts[key]; !exists { + c.order = append(c.order, key) + } + c.counts[key]++ + if len(c.kept[key]) < c.perShape { + c.kept[key] = append(c.kept[key], s) + added++ + } + } + return added +} + +// Samples 返回去重后的样本(按首次出现顺序),并回填 Occurrences。 +func (c *Collector) Samples() []Sample { + out := make([]Sample, 0, len(c.order)) + for _, key := range c.order { + for _, s := range c.kept[key] { + s.Meta.Occurrences = c.counts[key] + out = append(out, s) + } + } + return out +} + +// Stats 返回提取统计(Shapes/Written 在 Write 时补全)。 +func (c *Collector) Stats() ManifestStats { + s := c.stats + s.Shapes = len(c.order) + return s +} + +// WriteOptions 落盘选项。 +type WriteOptions struct { + // OutputDir 语料根目录(各类别落在其下的 real_inputs / real_stream_inputs / real_responses) + OutputDir string + // Source 采集条件,写进 manifest 供溯源 + Source ManifestSource + // DryRun 只统计不写文件 + DryRun bool + // Scrubbed 本次提取是否启用了脱敏(写进 manifest 供使用方判断语料可信度) + Scrubbed bool +} + +// Write 把样本落盘并生成 manifest.json,返回最终统计。 +// +// real_* 目录是本工具全域生成的派生物,每次提取先清空重建:文件名含结构指纹, +// 脱敏/指纹语义变化会改变哈希进而改变文件名,不清空会让旧文件成为孤儿—— +// 目录内容与 manifest 脱节,且会被按目录枚举的下游测试误读。 +// 手写语料目录(inputs / stream_inputs / expected)不在清理范围。 +func Write(samples []Sample, stats ManifestStats, opts WriteOptions) (ManifestStats, error) { + if !opts.DryRun { + for _, kind := range []SampleKind{KindRequest, KindStreamResponse, KindResponse} { + if err := os.RemoveAll(filepath.Join(opts.OutputDir, kind.dirOf())); err != nil { + return stats, fmt.Errorf("清理旧语料目录失败: %w", err) + } + } + } + manifest := Manifest{ + GeneratedAt: time.Now().Format(time.RFC3339), + Source: opts.Source, + Directions: map[string]int{}, + Formats: map[string]int{}, + Notes: map[string]string{ + "purpose": "真实流量提取的协议转换语料;请求语料驱动请求侧全部方向,响应/流式语料驱动对应上游格式的转换器", + "dedup": "按结构指纹去重,occurrences 为该形状在采集窗口内的出现次数", + "scrub": scrubNote(opts.Scrubbed), + "regenerate": "重新采集:team-api corpus-extract -o <本目录>", + }, + } + + for _, s := range samples { + if !opts.DryRun { + full := filepath.Join(opts.OutputDir, filepath.FromSlash(s.RelPath())) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return stats, fmt.Errorf("创建语料目录失败: %w", err) + } + if err := os.WriteFile(full, s.Body, 0o644); err != nil { + return stats, fmt.Errorf("写入语料 %s 失败: %w", s.RelPath(), err) + } + } + stats.Written++ + manifest.Entries = append(manifest.Entries, s.Meta) + + if s.Meta.ClientFormat != "" && s.Meta.UpstreamFormat != "" { + manifest.Directions[s.Meta.ClientFormat+"→"+s.Meta.UpstreamFormat]++ + } + manifest.Formats[string(s.Kind)+":"+s.Format]++ + } + + manifest.Stats = stats + sort.Slice(manifest.Entries, func(i, j int) bool { + return manifest.Entries[i].File < manifest.Entries[j].File + }) + + if opts.DryRun { + return stats, nil + } + + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return stats, fmt.Errorf("序列化 manifest 失败: %w", err) + } + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return stats, fmt.Errorf("创建输出目录失败: %w", err) + } + manifestPath := filepath.Join(opts.OutputDir, "corpus_manifest.json") + if err := os.WriteFile(manifestPath, append(raw, '\n'), 0o644); err != nil { + return stats, fmt.Errorf("写入 manifest 失败: %w", err) + } + return stats, nil +} + +// scrubNote manifest 中的脱敏说明。 +func scrubNote(scrubbed bool) string { + if scrubbed { + return "已脱敏:自由文本等长替换、内联数据换占位图;标识符与工具名一致映射(引用关系保留);" + + "判别式字段(type/role/finish_reason/model)与协议内置工具名保留原值。" + + "注意脱敏经过重新序列化,key 顺序与原文不同,不适合做字节级 wire 保真校验。" + } + return "未脱敏——落盘为报文原文,仅供确知无敏感数据的测试流量使用" +} diff --git a/internal/utility/corpus/corpus_test.go b/internal/utility/corpus/corpus_test.go new file mode 100644 index 0000000..ef12dcb --- /dev/null +++ b/internal/utility/corpus/corpus_test.go @@ -0,0 +1,410 @@ +package corpus + +import ( + "strings" + "testing" + "time" +) + +// ---------- 结构指纹 ---------- + +// TestFingerprint_IgnoresFreeText 指纹必须无视自由文本: +// 内容不同但结构相同的请求算同一形状,否则每条真实请求都是新形状、去重完全失效。 +func TestFingerprint_IgnoresFreeText(t *testing.T) { + a := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"今天天气怎么样"}]}`) + b := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"帮我写一段 Go 代码,要求实现快速排序"}]}`) + + ha, ok := Fingerprint(a) + if !ok { + t.Fatal("应能解析") + } + hb, _ := Fingerprint(b) + if ha != hb { + t.Errorf("内容不同、结构相同的请求指纹应一致\n a=%s\n b=%s", ha, hb) + } +} + +// TestFingerprint_IgnoresSessionIDs 会话级标识符随会话变化、与结构无关—— +// 两个会话的同形状请求指纹必须一致,否则跨会话采集时去重会碎片化。 +// 覆盖已知 key(session_id 等)与未知 key(裸 UUID 值形态兜底)、 +// 带轮次后缀的 x-codex-window-id(每轮都变,危害最大)。 +func TestFingerprint_IgnoresSessionIDs(t *testing.T) { + a := []byte(`{"session_id":"01a0916c-85ba-7121-8819-8d582bfe20e6","thread_id":"01a0916c-85ba-7121-8819-8d582bfe20e6","prompt_cache_key":"01a0916c-85ba-7121-8819-8d582bfe20e6","x-codex-window-id":"01a0916c-85ba-7121-8819-8d582bfe20e6:0","some_future_id_field":"aa11bb22-cc33-dd44-ee55-ff6677889900","input":[{"type":"message","role":"user","content":"hi"}]}`) + b := []byte(`{"session_id":"77c0ffee-dead-beef-0000-123456789abc","thread_id":"77c0ffee-dead-beef-0000-123456789abc","prompt_cache_key":"77c0ffee-dead-beef-0000-123456789abc","x-codex-window-id":"77c0ffee-dead-beef-0000-123456789abc:17","some_future_id_field":"00112233-4455-6677-8899-aabbccddeeff","input":[{"type":"message","role":"user","content":"另一个会话的同样结构"}]}`) + + ha, ok := Fingerprint(a) + if !ok { + t.Fatal("应能解析") + } + hb, _ := Fingerprint(b) + if ha != hb { + t.Error("仅会话标识符不同的请求指纹应一致(跨会话去重会被碎片化)") + } +} + +// TestFingerprint_DistinguishesStructure 判别式字段与结构差异必须区分开, +// 否则不同的转换分支会被折叠成一条语料,覆盖面反而收窄。 +func TestFingerprint_DistinguishesStructure(t *testing.T) { + base := []byte(`{"messages":[{"role":"user","content":"x"}]}`) + + cases := map[string]string{ + "content 由字符串变数组": `{"messages":[{"role":"user","content":[{"type":"text","text":"x"}]}]}`, + "多了图片块": `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"x"}}]}]}`, + "role 不同": `{"messages":[{"role":"assistant","content":"x"}]}`, + "多了 tools 字段": `{"messages":[{"role":"user","content":"x"}],"tools":[{"type":"function"}]}`, + } + + hBase, _ := Fingerprint(base) + for name, body := range cases { + t.Run(name, func(t *testing.T) { + h, ok := Fingerprint([]byte(body)) + if !ok { + t.Fatal("应能解析") + } + if h == hBase { + t.Error("结构不同的请求不应算作同一形状") + } + }) + } +} + +// TestFingerprint_ArrayLengthBucketing 数组长度按 1/2/3/many 分桶: +// 「单轮」「多轮」是结构差异要区分,而「50 轮」与「51 轮」是长度噪音要折叠。 +func TestFingerprint_ArrayLengthBucketing(t *testing.T) { + mk := func(n int) []byte { + items := make([]string, n) + for i := range items { + items[i] = `{"role":"user","content":"x"}` + } + return []byte(`{"messages":[` + strings.Join(items, ",") + `]}`) + } + + h1, _ := Fingerprint(mk(1)) + h2, _ := Fingerprint(mk(2)) + h50, _ := Fingerprint(mk(50)) + h51, _ := Fingerprint(mk(51)) + + if h1 == h2 { + t.Error("1 条与 2 条消息应算不同形状") + } + if h50 != h51 { + t.Error("50 条与 51 条消息应折叠为同一形状(都落在 many 桶)") + } + if h2 == h50 { + t.Error("2 条与 50 条消息应算不同形状") + } +} + +func TestFingerprint_NonJSON(t *testing.T) { + if _, ok := Fingerprint([]byte("data: {...}\n\n")); ok { + t.Error("非 JSON 应返回 ok=false,交由 FingerprintStream 处理") + } +} + +// ---------- 流式指纹 ---------- + +// TestFingerprintStream_CollapsesDeltaCount 流式指纹必须折叠增量帧数量: +// 一次回答 3 个 token 还是 300 个,结构上是同一件事。 +func TestFingerprintStream_CollapsesDeltaCount(t *testing.T) { + mk := func(n int) []byte { + var sb strings.Builder + sb.WriteString("data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n") + for i := 0; i < n; i++ { + sb.WriteString("data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n") + } + sb.WriteString("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + return []byte(sb.String()) + } + + if FingerprintStream(mk(5)) != FingerprintStream(mk(500)) { + t.Error("增量帧数量不同、结构相同的流应算同一形状") + } +} + +// TestFingerprintStream_DistinguishesEventShape 帧类型/事件名不同必须区分—— +// 带工具调用的流与纯文本流是两种转换路径。 +func TestFingerprintStream_DistinguishesEventShape(t *testing.T) { + text := []byte("data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n") + tool := []byte("data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"f\"}}]}}]}\n\n") + if FingerprintStream(text) == FingerprintStream(tool) { + t.Error("文本流与工具调用流应算不同形状") + } + + // SSE 事件名是 Claude/Responses 协议的一部分,必须进指纹 + e1 := []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\"}\n\n") + e2 := []byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + if FingerprintStream(e1) == FingerprintStream(e2) { + t.Error("不同事件名的流应算不同形状") + } +} + +func TestFingerprintStream_NDJSON(t *testing.T) { + a := []byte("{\"message\":{\"content\":\"a\"},\"done\":false}\n{\"done\":true}\n") + b := []byte("{\"message\":{\"content\":\"完全不同的内容\"},\"done\":false}\n{\"done\":true}\n") + if FingerprintStream(a) != FingerprintStream(b) { + t.Error("NDJSON(Ollama)内容不同、结构相同应算同一形状") + } +} + +// ---------- 特征识别 ---------- + +func TestDetectFeatures(t *testing.T) { + tests := []struct { + name string + body string + want []string + }{ + {"纯文本", `{"messages":[{"role":"user","content":"hi"}]}`, nil}, + {"带工具", `{"messages":[],"tools":[{"type":"function"}]}`, []string{"tools"}}, + { + "工具循环", + `{"messages":[{"role":"assistant","tool_calls":[{"id":"c"}]},{"role":"tool","content":"r"}]}`, + []string{"toolloop"}, + }, + {"图片", `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"x"}}]}]}`, []string{"image"}}, + {"Gemini 图片", `{"contents":[{"parts":[{"inlineData":{"mimeType":"image/png"}}]}]}`, []string{"image"}}, + {"思考", `{"messages":[],"reasoning_effort":"high"}`, []string{"thinking"}}, + {"Claude 思考", `{"messages":[],"thinking":{"type":"enabled"}}`, []string{"thinking"}}, + {"联网搜索", `{"messages":[],"web_search_options":{}}`, []string{"websearch"}}, + {"Gemini 搜索", `{"contents":[],"tools":[{"googleSearch":{}}]}`, []string{"tools", "websearch"}}, + { + "多轮", + `{"messages":[{"role":"user"},{"role":"assistant"},{"role":"user"}]}`, + []string{"multiturn"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectFeatures([]byte(tt.body)) + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Errorf("DetectFeatures() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectStreamFeatures(t *testing.T) { + stream := []byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"f\"}}]},\"groundingMetadata\":{}}]}\n\n") + got := DetectStreamFeatures(stream) + joined := strings.Join(got, ",") + if !strings.Contains(joined, "toolloop") || !strings.Contains(joined, "websearch") { + t.Errorf("流式特征识别遗漏: %v", got) + } +} + +// ---------- 文件名 ---------- + +func TestSampleFileName(t *testing.T) { + s := Sample{ + Kind: KindRequest, + Format: "openai", + Features: []string{"image", "tools"}, + ShapeHash: "abcdef0123456789", + } + if got := s.FileName(); got != "openai__image-tools_abcdef01.json" { + t.Errorf("FileName() = %q", got) + } + if got := s.RelPath(); got != "real_inputs/openai__image-tools_abcdef01.json" { + t.Errorf("RelPath() = %q", got) + } + + // 无特征时用 basic,与手写语料的命名习惯一致 + s.Features = nil + if got := s.FileName(); got != "openai__basic_abcdef01.json" { + t.Errorf("无特征时 FileName() = %q", got) + } + + // 流式语料落在独立目录、用 .txt(SSE 原文不是 JSON) + s.Kind = KindStreamResponse + if got := s.RelPath(); got != "real_stream_inputs/openai__basic_abcdef01.txt" { + t.Errorf("流式 RelPath() = %q", got) + } +} + +// ---------- 去重收集 ---------- + +func mkRecord(id int64, clientBody, respBody string, stream bool) Record { + return Record{ + ID: id, + RequestID: "req", + RelayMode: "chat_completions", + IsStream: stream, + UpstreamStatus: 200, + CapturedAt: time.Now(), + ClientReqBody: []byte(clientBody), + UpstreamRespBody: []byte(respBody), + Conversion: Conversion{ClientFormat: "openai", UpstreamFormat: "claude"}, + } +} + +// TestCollector_DedupByShape 同形状只保留代表样本,但出现次数要累计—— +// occurrences 反映真实流量权重,是判断哪些形状值得提升进精选金样本的依据。 +func TestCollector_DedupByShape(t *testing.T) { + c := NewCollector(1, ScrubOptions{}) + + // 三条内容不同、结构相同的请求 + c.Add(mkRecord(1, `{"messages":[{"role":"user","content":"问题一"}]}`, "", false)) + c.Add(mkRecord(2, `{"messages":[{"role":"user","content":"另一个完全不同的问题"}]}`, "", false)) + c.Add(mkRecord(3, `{"messages":[{"role":"user","content":"第三个"}]}`, "", false)) + // 一条结构不同的 + c.Add(mkRecord(4, `{"messages":[{"role":"user","content":"x"}],"tools":[{"type":"function"}]}`, "", false)) + + samples := c.Samples() + if len(samples) != 2 { + t.Fatalf("应去重为 2 个样本,实际 %d: %+v", len(samples), samples) + } + + stats := c.Stats() + if stats.Scanned != 4 { + t.Errorf("Scanned = %d, want 4", stats.Scanned) + } + if stats.Shapes != 2 { + t.Errorf("Shapes = %d, want 2", stats.Shapes) + } + + // 第一种形状出现 3 次 + var basic *Sample + for i := range samples { + if len(samples[i].Features) == 0 { + basic = &samples[i] + } + } + if basic == nil { + t.Fatal("未找到无特征样本") + } + if basic.Meta.Occurrences != 3 { + t.Errorf("Occurrences = %d, want 3(同形状出现次数需累计)", basic.Meta.Occurrences) + } +} + +func TestCollector_PerShapeLimit(t *testing.T) { + c := NewCollector(2, ScrubOptions{}) + for i := 0; i < 5; i++ { + c.Add(mkRecord(int64(i), `{"messages":[{"role":"user","content":"x"}]}`, "", false)) + } + if got := len(c.Samples()); got != 2 { + t.Errorf("per-shape=2 应保留 2 条,实际 %d", got) + } +} + +// TestCollector_SkipsRowsWithoutConversion 缺 conversion 元数据的行无法判断协议方向, +// 计入 SkippedNoConv 而非静默丢弃——统计要能反映采集质量。 +func TestCollector_SkipsRowsWithoutConversion(t *testing.T) { + c := NewCollector(1, ScrubOptions{}) + r := mkRecord(1, `{"messages":[]}`, "", false) + r.Conversion = Conversion{} + c.Add(r) + + if len(c.Samples()) != 0 { + t.Error("缺 conversion 的记录不应产出语料") + } + if c.Stats().SkippedNoConv != 1 { + t.Errorf("SkippedNoConv = %d, want 1", c.Stats().SkippedNoConv) + } +} + +// TestBuild_StreamVsNonStream is_stream 决定响应语料落进流式目录还是非流式目录。 +func TestBuild_StreamVsNonStream(t *testing.T) { + nonStream := mkRecord(1, `{"messages":[]}`, `{"id":"c","choices":[{"message":{"content":"hi"}}]}`, false) + kinds := map[SampleKind]bool{} + for _, s := range Build(nonStream, nil) { + kinds[s.Kind] = true + } + if !kinds[KindRequest] || !kinds[KindResponse] { + t.Errorf("非流式应产出 request + response 两类语料: %v", kinds) + } + + stream := mkRecord(2, `{"messages":[]}`, "data: {\"type\":\"message_stop\"}\n\n", true) + kinds = map[SampleKind]bool{} + for _, s := range Build(stream, nil) { + kinds[s.Kind] = true + } + if !kinds[KindStreamResponse] { + t.Errorf("流式应产出 stream_response 语料: %v", kinds) + } + if kinds[KindResponse] { + t.Error("流式不应产出非流式响应语料") + } +} + +// TestBuild_SkipsFailedUpstream 上游非 200 时段3 是错误体,不能当作响应语料。 +func TestBuild_SkipsFailedUpstream(t *testing.T) { + r := mkRecord(1, `{"messages":[]}`, `{"error":{"message":"boom"}}`, false) + r.UpstreamStatus = 500 + for _, s := range Build(r, nil) { + if s.Kind == KindResponse { + t.Error("上游失败的响应体不应成为语料") + } + } +} + +// ---------- 体积控制 ---------- + +func TestShrinkInlineData(t *testing.T) { + long := strings.Repeat("A", 20000) + body := []byte(`{"image_url":{"url":"data:image/png;base64,` + long + `"}}`) + + got := ShrinkInlineData(body, 8192) + if len(got) >= len(body) { + t.Errorf("超长内联数据未被压缩: %d → %d", len(body), len(got)) + } + if !strings.Contains(string(got), placeholderPNG) { + t.Error("应替换为占位 PNG") + } + // 结构必须保留(转换器靠 mimeType 与字段位置工作) + if !strings.Contains(string(got), "data:image/png;base64,") { + t.Error("data URL 前缀应保留") + } + + // 阈值内的数据不动 + short := []byte(`{"data":"` + strings.Repeat("A", 100) + `"}`) + if string(ShrinkInlineData(short, 8192)) != string(short) { + t.Error("阈值内的数据不应改动") + } + // 关闭 + if string(ShrinkInlineData(body, 0)) != string(body) { + t.Error("maxBytes=0 应原样返回") + } +} + +// TestFingerprint_IgnoresCJKValues 未知 key 下的短中文是用户内容而非枚举 +// (中文不含空格,长度也可能 <40,「无空格即枚举」启发式对 CJK 失效), +// 折叠后两条内容不同的请求才是同一形状。 +func TestFingerprint_IgnoresCJKValues(t *testing.T) { + a := []byte(`{"custom_note":"先看仓库","input":[{"type":"message","role":"user","content":"hi"}]}`) + b := []byte(`{"custom_note":"再改代码","input":[{"type":"message","role":"user","content":"hi"}]}`) + ha, ok := Fingerprint(a) + if !ok { + t.Fatal("应能解析") + } + hb, _ := Fingerprint(b) + if ha != hb { + t.Error("仅短中文内容不同的请求指纹应一致") + } +} + +// TestFingerprint_CollapsesIDKeyedMaps 按 id 索引的对象 key 必须折叠: +// Responses 的 usage 明细按消息 id 建 map,id 由服务端逐次生成—— +// 原样进指纹的话每个流都是独一无二的形状,去重永远失效。 +// 同时校验正常字段名(如 item_type)不得被误折叠。 +func TestFingerprint_CollapsesIDKeyedMaps(t *testing.T) { + a := []byte(`{"usage":{"input_tokens_details":{"at_4aab6a0b-9283-5e0c-b342-a2124032a32e":{"input_tokens":5},"msg_01a0916c-9211-7c20-ad68-2c2cae86a278":{"input_tokens":7}}},"object":"response"}`) + b := []byte(`{"usage":{"input_tokens_details":{"ffffffffffffffffffffffffffffffff":{"input_tokens":5},"msg_77c0ffee-dead-beef-0000-123456789abc":{"input_tokens":7}}},"object":"response"}`) + ha, ok := Fingerprint(a) + if !ok { + t.Fatal("应能解析") + } + hb, _ := Fingerprint(b) + if ha != hb { + t.Error("仅消息 id 不同的 usage 明细应算同一形状") + } + + c := []byte(`{"obj":{"item_type":{"a":1}}}`) + d := []byte(`{"obj":{"other_name":{"a":1}}}`) + hc, _ := Fingerprint(c) + hd, _ := Fingerprint(d) + if hc == hd { + t.Error("正常字段名是结构差异,不得被 id-keyed 折叠误伤") + } +} diff --git a/internal/utility/corpus/sample.go b/internal/utility/corpus/sample.go new file mode 100644 index 0000000..7bd7823 --- /dev/null +++ b/internal/utility/corpus/sample.go @@ -0,0 +1,350 @@ +package corpus + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// Conversion 调试日志的 conversion JSONB 字段(协议转换方向元数据)。 +type Conversion struct { + ClientFormat string `json:"client_format"` + UpstreamFormat string `json:"upstream_format"` + Chain []string `json:"chain,omitempty"` + Bridge string `json:"bridge,omitempty"` +} + +// Record 一条已解码的调试日志(chn_debug_logs 的一行)。 +// 只取提取语料所需字段:段1(客户端请求)与段3(上游响应)是转换器的**输入**; +// 段2/段4 是当时的转换**输出**,属于「生产一致性校验」的范畴,本包不处理。 +type Record struct { + ID int64 + RequestID string + ChannelType int + ModelName string + UpstreamModel string + RelayMode string + InboundPath string + IsStream bool + UpstreamStatus int + CapturedAt time.Time + + ClientReqBody []byte // 段1 + UpstreamRespBody []byte // 段3 + Conversion Conversion +} + +// SampleKind 语料类别,决定落盘目录与文件扩展名。 +type SampleKind string + +const ( + // KindRequest 客户端请求语料(段1)→ 驱动请求侧转换的全部方向 + KindRequest SampleKind = "request" + // KindStreamResponse 上游流式响应语料(段3,is_stream)→ 驱动流式转换 + KindStreamResponse SampleKind = "stream_response" + // KindResponse 上游非流式响应语料(段3)→ 驱动响应侧转换 + KindResponse SampleKind = "response" +) + +// dirOf 各类语料的落盘子目录。与既有手写语料(inputs / stream_inputs)分开存放: +// 真实语料量大且会持续增长,混进去会让手写的精选集难以阅读, +// 且金样本套件对手写目录有「文件名必须匹配已知场景」的校验。 +func (k SampleKind) dirOf() string { + switch k { + case KindRequest: + return "real_inputs" + case KindStreamResponse: + return "real_stream_inputs" + default: + return "real_responses" + } +} + +func (k SampleKind) ext() string { + if k == KindStreamResponse { + return ".txt" + } + return ".json" +} + +// Sample 一条待落盘的语料。 +type Sample struct { + Kind SampleKind + Format string // 目录内的格式前缀:请求取 client_format,响应取 upstream_format + Features []string + ShapeHash string + Body []byte + + Meta ManifestEntry +} + +// FileName 语料文件名:<格式>__<特征>_<指纹前8位>. +// 与手写语料的 <格式>__<场景> 约定保持形似,便于人工浏览时归类。 +func (s Sample) FileName() string { + feat := "basic" + if len(s.Features) > 0 { + feat = strings.Join(s.Features, "-") + } + short := s.ShapeHash + if len(short) > 8 { + short = short[:8] + } + return fmt.Sprintf("%s__%s_%s%s", s.Format, feat, short, s.Kind.ext()) +} + +// RelPath 语料相对输出根目录的路径。 +func (s Sample) RelPath() string { + return s.Kind.dirOf() + "/" + s.FileName() +} + +// ManifestEntry manifest 中的一条溯源记录。 +type ManifestEntry struct { + File string `json:"file"` + Kind string `json:"kind"` + Format string `json:"format"` + Features []string `json:"features,omitempty"` + ShapeHash string `json:"shape_hash"` + Occurrences int `json:"occurrences"` // 该形状在采集窗口内出现次数,反映真实流量权重 + + ClientFormat string `json:"client_format,omitempty"` + UpstreamFormat string `json:"upstream_format,omitempty"` + Chain []string `json:"chain,omitempty"` + Bridge string `json:"bridge,omitempty"` + RelayMode string `json:"relay_mode,omitempty"` + IsStream bool `json:"is_stream"` + ChannelType int `json:"channel_type,omitempty"` + ModelName string `json:"model_name,omitempty"` + UpstreamModel string `json:"upstream_model,omitempty"` + + SourceRequestID string `json:"source_request_id,omitempty"` + SourceLogID int64 `json:"source_log_id,omitempty"` + CapturedAt string `json:"captured_at,omitempty"` +} + +// Manifest 语料清单,随语料一同落盘,用于溯源与统计。 +type Manifest struct { + GeneratedAt string `json:"generated_at"` + Source ManifestSource `json:"source"` + Stats ManifestStats `json:"stats"` + Entries []ManifestEntry `json:"entries"` + Directions map[string]int `json:"directions,omitempty"` // "client→upstream" → 样本数 + Formats map[string]int `json:"formats,omitempty"` // 格式 → 样本数 + Notes map[string]string `json:"notes,omitempty"` +} + +// ManifestSource 采集条件。 +type ManifestSource struct { + Since string `json:"since,omitempty"` + Until string `json:"until,omitempty"` + Channel string `json:"channel,omitempty"` + Format string `json:"format,omitempty"` + Limit int `json:"limit"` +} + +// ManifestStats 提取统计。 +type ManifestStats struct { + Scanned int `json:"scanned"` // 扫描的日志行数 + SkippedNoConv int `json:"skipped_no_conv"` // 缺 conversion 元数据被跳过 + SkippedEmpty int `json:"skipped_empty"` // 报文体为空 + Shapes int `json:"shapes"` // 去重后的不同形状数 + Written int `json:"written"` // 实际落盘文件数 +} + +// ---------- 特征识别 ---------- + +// featureMarkers 结构特征 → 触发它的 key 或判别式值。 +// +// 采用跨格式的通用扫描(遍历 JSON 的全部 key 与判别式字符串),而非按格式写四套解析: +// 各协议表达同一能力的字段名不同但都在这张表里,新增格式无需改代码。 +// 特征只用于文件命名与浏览归类,识别偏差不影响语料本身。 +var featureMarkers = []struct { + feature string + markers []string +}{ + {"tools", []string{"tools", "functionDeclarations", "tool_choice", "toolConfig"}}, + {"toolloop", []string{"tool_result", "function_call_output", "functionResponse", "tool_calls", "tool_use", "functionCall", "function_call"}}, + {"image", []string{"image_url", "inlineData", "input_image", "inline_data"}}, + {"thinking", []string{"thinking", "thinkingConfig", "reasoning", "reasoning_effort", "reasoning_content", "thought", "thoughtSignature"}}, + {"websearch", []string{"web_search_options", "googleSearch", "googleSearchRetrieval", "web_search", "web_search_20250305", "groundingMetadata"}}, + {"audio", []string{"input_audio", "audio"}}, + {"file", []string{"input_file", "fileData", "file_data"}}, +} + +// DetectFeatures 扫描 JSON 体识别结构特征。多轮对话(消息数 > 2)另加 multiturn。 +func DetectFeatures(body []byte) []string { + var v any + if err := json.Unmarshal(body, &v); err != nil { + return nil + } + return detectFeaturesFromValue(v) +} + +// DetectStreamFeatures 扫描流式语料:逐帧解析 data 负载后合并特征。 +func DetectStreamFeatures(body []byte) []string { + found := map[string]bool{} + for _, f := range parseStreamFrames(body) { + var v any + if err := json.Unmarshal([]byte(f.data), &v); err != nil { + continue + } + for _, feat := range detectFeaturesFromValue(v) { + found[feat] = true + } + } + return sortedFeatures(found) +} + +func detectFeaturesFromValue(v any) []string { + seen := map[string]bool{} + collectMarkers(v, seen) + + found := map[string]bool{} + for _, fm := range featureMarkers { + for _, m := range fm.markers { + if seen[m] { + found[fm.feature] = true + break + } + } + } + if isMultiTurn(v) { + found["multiturn"] = true + } + return sortedFeatures(found) +} + +// collectMarkers 递归收集全部对象 key 与判别式字符串值(type/role 等的取值本身就是标记, +// 如 content 块的 "tool_result"、Responses input 项的 "function_call_output")。 +func collectMarkers(v any, seen map[string]bool) { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + seen[k] = true + collectMarkers(val, seen) + } + case []any: + for _, el := range t { + collectMarkers(el, seen) + } + case string: + if len(t) <= enumLikeMax && !strings.ContainsAny(t, " \t\n\r") { + seen[t] = true + } + } +} + +// isMultiTurn 顶层消息数组长度 > 2 即视为多轮(各协议的消息数组字段名不同,逐一尝试)。 +func isMultiTurn(v any) bool { + obj, ok := v.(map[string]any) + if !ok { + return false + } + for _, key := range []string{"messages", "contents", "input"} { + if arr, ok := obj[key].([]any); ok && len(arr) > 2 { + return true + } + } + return false +} + +func sortedFeatures(found map[string]bool) []string { + out := make([]string, 0, len(found)) + for f := range found { + out = append(out, f) + } + sort.Strings(out) + return out +} + +// ---------- Record → Sample ---------- + +// Build 从一条调试日志提取语料样本(请求侧 + 响应侧,各自可能为空)。 +// 不做去重(由调用方按 ShapeHash 聚合)。 +// +// scrub 非 nil 时对报文体做脱敏,且**请求与响应共用同一个 Scrubber**—— +// 二者的标识符映射必须一致,否则记录内的引用关系会在两个文件之间对不上。 +// +// 结构指纹在**脱敏之后**计算:脱敏保持结构不变,但会重排 key 顺序、 +// 归一化数值格式,在原文上算指纹会让同形状的记录因序列化差异被判成不同形状。 +func Build(r Record, scrub *Scrubber) []Sample { + var samples []Sample + if scrub == nil { + scrub = NewScrubber(ScrubOptions{}) + } + clientBody := scrub.ScrubJSON(r.ClientReqBody) + respBody := r.UpstreamRespBody + if r.IsStream { + respBody = scrub.ScrubStream(respBody) + } else { + respBody = scrub.ScrubJSON(respBody) + } + + if len(clientBody) > 0 && r.Conversion.ClientFormat != "" { + if hash, ok := Fingerprint(clientBody); ok { + samples = append(samples, Sample{ + Kind: KindRequest, + Format: r.Conversion.ClientFormat, + Features: DetectFeatures(clientBody), + ShapeHash: hash, + Body: clientBody, + Meta: baseMeta(r), + }) + } + } + + if len(respBody) > 0 && r.Conversion.UpstreamFormat != "" && r.UpstreamStatus == 200 { + if r.IsStream { + samples = append(samples, Sample{ + Kind: KindStreamResponse, + Format: r.Conversion.UpstreamFormat, + Features: DetectStreamFeatures(respBody), + ShapeHash: FingerprintStream(respBody), + Body: respBody, + Meta: baseMeta(r), + }) + } else if hash, ok := Fingerprint(respBody); ok { + samples = append(samples, Sample{ + Kind: KindResponse, + Format: r.Conversion.UpstreamFormat, + Features: DetectFeatures(respBody), + ShapeHash: hash, + Body: respBody, + Meta: baseMeta(r), + }) + } + } + + // 回填各样本自身的 meta 字段 + for i := range samples { + s := &samples[i] + s.Meta.File = s.RelPath() + s.Meta.Kind = string(s.Kind) + s.Meta.Format = s.Format + s.Meta.Features = s.Features + s.Meta.ShapeHash = s.ShapeHash + } + return samples +} + +func baseMeta(r Record) ManifestEntry { + captured := "" + if !r.CapturedAt.IsZero() { + captured = r.CapturedAt.Format(time.RFC3339) + } + return ManifestEntry{ + ClientFormat: r.Conversion.ClientFormat, + UpstreamFormat: r.Conversion.UpstreamFormat, + Chain: r.Conversion.Chain, + Bridge: r.Conversion.Bridge, + RelayMode: r.RelayMode, + IsStream: r.IsStream, + ChannelType: r.ChannelType, + ModelName: r.ModelName, + UpstreamModel: r.UpstreamModel, + SourceRequestID: r.RequestID, + SourceLogID: r.ID, + CapturedAt: captured, + } +} diff --git a/internal/utility/corpus/scrub.go b/internal/utility/corpus/scrub.go new file mode 100644 index 0000000..4b667c9 --- /dev/null +++ b/internal/utility/corpus/scrub.go @@ -0,0 +1,389 @@ +package corpus + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// 语料脱敏。 +// +// 核心判断:**协议转换器只关心结构,不关心内容**。因此自由文本可以激进替换, +// 而不损失任何测试价值。但「激进」必须止步于两类东西,否则语料会变成 +// 假阳性的来源——测试失败了,却不是代码的错: +// +// 1. **引用一致性**:tool_call_id 在 assistant.tool_calls 与后续 tool_result 之间 +// 必须替换后仍然相等;Gemini 的 functionCall ↔ functionResponse 靠**函数名**关联。 +// 打断任何一条,工具循环链就断了,而工具循环正是转换最容易出 bug 的地方。 +// 2. **判别式与行为相关字段**:type / role / finish_reason 决定转换分支; +// model 决定模型相关分支(Claude 默认 max_tokens、Vertex 按名选端点); +// 协议内置工具名(web_search 等)决定服务端工具识别。这些一律保留原值。 +// +// 另有两处形态约束:tool 的 arguments 是**JSON 字符串**,替换后必须仍是合法 JSON +// (否则转换器走进解析失败分支,行为与线上不同);base64 内联数据替换为可解码的 +// 占位图片(而非任意字符串),使图片处理路径仍能正常工作。 + +// ScrubOptions 脱敏选项。 +type ScrubOptions struct { + // Enabled 为 false 时所有 Scrub 方法原样返回。 + Enabled bool + // KeepToolNames 保留自定义工具名原值。默认 false(按映射一致替换)—— + // 自定义工具名可能泄漏业务逻辑(如 query_internal_crm)。 + // 协议内置工具名(web_search 等)不受此选项影响,始终保留。 + KeepToolNames bool + // MaxTextLen 单个字符串值的填充长度上限(字节,0=不限制)。 + // 等长替换本为覆盖「token 估算/截断阈值」等长度敏感路径,但对转换器测试而言 + // 长度的**量级**即足够;不设上限时,携带完整会话历史的请求(多轮工具循环) + // 会让语料膨胀到数百 MB(实测 340MB),此处让超长文本在上限处截断填充。 + MaxTextLen int +} + +// Scrubber 单条记录范围的脱敏器。 +// +// 作用域是「一条记录」而非「一次提取」:同一记录的请求与响应共用映射表, +// 保证记录内的标识符引用关系完整;跨记录无需一致(每个语料文件独立使用)。 +type Scrubber struct { + opts ScrubOptions + idMap map[string]string + nameMap map[string]string +} + +// NewScrubber 创建脱敏器。 +func NewScrubber(opts ScrubOptions) *Scrubber { + return &Scrubber{ + opts: opts, + idMap: make(map[string]string), + nameMap: make(map[string]string), + } +} + +// ---------- 字段分类 ---------- + +// idKeys 承载标识符的字段。这些值必须**一致映射**(同一原值 → 同一新值), +// 否则跨消息的引用关系断裂。 +var idKeys = map[string]bool{ + "id": true, "call_id": true, "tool_call_id": true, "tool_use_id": true, + "item_id": true, "message_id": true, "response_id": true, "responseId": true, + "previous_response_id": true, "container_id": true, "approval_request_id": true, + // 会话级标识符(codex 等客户端把同一会话 UUID 塞进大量字段,含 + // x-codex-* 这类 header 风格的连字符 key):值随会话变化且属隐私,需脱敏; + // 无跨字段引用语义,一致映射即可。key 名无法穷举,裸 UUID 另按值形态兜底 + "prompt_cache_key": true, "session_id": true, "thread_id": true, + "turn_id": true, "root_turn_id": true, + "x-codex-window-id": true, "x-codex-installation-id": true, +} + +// nameKeys 承载工具名的字段。Gemini 的 functionCall ↔ functionResponse 仅靠名字关联, +// 因此同样必须一致映射。 +var nameKeys = map[string]bool{"name": true, "tool_name": true} + +// reservedToolNames 协议内置(服务端)工具名,转换器按名识别,必须原样保留。 +var reservedToolNames = map[string]bool{ + "web_search": true, "web_search_preview": true, "file_search": true, + "code_execution": true, "code_interpreter": true, "computer": true, + "computer_use_preview": true, "bash": true, "text_editor": true, + "str_replace_editor": true, "str_replace_based_edit_tool": true, + "googleSearch": true, "googleSearchRetrieval": true, +} + +// behaviorKeys 影响转换分支的字段,保留原值。 +// +// model 尤其重要:Claude 默认 max_tokens 按模型名分档、Vertex 按模型名是否含 "claude" +// 选择端点——替换掉会让语料不再触发这些分支。 +var behaviorKeys = map[string]bool{ + "model": true, "type": true, "role": true, "object": true, "status": true, + "finish_reason": true, "stop_reason": true, "finishReason": true, + "mimeType": true, "media_type": true, "mime_type": true, "encoding_format": true, + "effort": true, "summary": true, "bridge": true, "format": true, + "detail": true, "service_tier": true, "search_context_size": true, + "done_reason": true, "blockReason": true, "category": true, "threshold": true, +} + +// signatureKeys 不透明签名/加密块,无结构语义,替换为固定标记。 +var signatureKeys = map[string]bool{ + "signature": true, "thoughtSignature": true, "thought_signature": true, + "encrypted_content": true, +} + +// userDataKeys 进入这些字段后,其**整棵子树**的字符串都视为用户数据强制脱敏 +// (不再按「短且无空格即枚举」的启发式放行)——工具参数里的 +// {"location":"Boston"} 正是靠这条才不会漏。 +var userDataKeys = map[string]bool{ + "arguments": true, "args": true, "input": true, "parameters": true, + "input_schema": true, "inputSchema": true, "response": true, + "content": true, "text": true, "output": true, "metadata": true, +} + +// secretPattern 常见凭证形态:无论在哪个字段、是否「看起来像枚举」,一律脱敏。 +// 用户完全可能把密钥粘在提示词里。 +var secretPattern = regexp.MustCompile(`(?i)\b(sk-[A-Za-z0-9_-]{16,}|AIza[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|Bearer\s+[A-Za-z0-9._-]{20,}|eyJ[A-Za-z0-9._-]{20,})`) + +// dataURLPattern data: URL 的前缀部分(保留 mime 与 base64 标记,替换载荷)。 +var dataURLPattern = regexp.MustCompile(`^(data:[^;,]*;base64,)`) + +// ---------- 入口 ---------- + +// ScrubJSON 脱敏 JSON 报文体。非 JSON 或未启用时原样返回。 +// +// 注意:脱敏经过「解析 → 改写 → 重新序列化」,输出的 key 顺序与空白与原文不同。 +// 语料的用途是喂给转换器(它本就要重新解析),因此语义等价即可; +// 但这意味着脱敏后的语料不再适合做字节级的 wire 保真校验。 +func (s *Scrubber) ScrubJSON(body []byte) []byte { + if !s.opts.Enabled || len(body) == 0 { + return body + } + var v any + if err := json.Unmarshal(body, &v); err != nil { + return body + } + out, err := json.Marshal(s.scrubValue(v, "", false)) + if err != nil { + return body + } + return out +} + +// ScrubStream 脱敏 SSE / NDJSON 流:逐帧脱敏 data 负载,保留事件名与帧结构。 +// [DONE] 等非 JSON 哨兵原样保留(它们是协议的一部分)。 +func (s *Scrubber) ScrubStream(body []byte) []byte { + if !s.opts.Enabled || len(body) == 0 { + return body + } + text := string(body) + if !strings.Contains(text, "data:") { + return s.scrubNDJSON(text) + } + + var sb strings.Builder + for _, block := range strings.Split(text, "\n\n") { + if strings.TrimSpace(block) == "" { + continue + } + for _, line := range strings.Split(block, "\n") { + switch { + case strings.HasPrefix(line, "data:"): + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + sb.WriteString("data: " + string(s.ScrubJSON([]byte(payload))) + "\n") + case strings.TrimSpace(line) == "": + // 帧内空行忽略 + default: + // event: / id: / retry: 等元信息行原样保留 + sb.WriteString(line + "\n") + } + } + sb.WriteString("\n") + } + return []byte(sb.String()) +} + +func (s *Scrubber) scrubNDJSON(text string) []byte { + var sb strings.Builder + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + sb.Write(s.ScrubJSON([]byte(line))) + sb.WriteString("\n") + } + return []byte(sb.String()) +} + +// ---------- 递归改写 ---------- + +// scrubValue 递归脱敏。key 为当前值所在字段名;inUserData 表示已处于用户数据子树内 +// (此时字符串一律脱敏,不再走枚举启发式)。 +func (s *Scrubber) scrubValue(v any, key string, inUserData bool) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = s.scrubValue(val, k, inUserData || userDataKeys[k]) + } + return out + + case []any: + out := make([]any, 0, len(t)) + for _, el := range t { + // 数组元素继承父字段名:tools[] 的元素仍按 tools 语境处理 + out = append(out, s.scrubValue(el, key, inUserData)) + } + return out + + case string: + return s.scrubString(t, key, inUserData) + + default: + // 数字 / 布尔 / null:不含隐私且影响转换行为(max_tokens、usage 等),保留 + return v + } +} + +func (s *Scrubber) scrubString(val, key string, inUserData bool) string { + if val == "" { + return val + } + + // ① 标识符:一致映射,保住跨消息引用。裸 UUID 形态的值无论 key 名一律 + // 按标识符处理——客户端会把会话/安装标识塞进任意命名的字段,key 不可枚举 + if idKeys[key] || uuidPattern.MatchString(val) { + return s.mapID(val) + } + // ② 工具名:一致映射(内置名保留) + if nameKeys[key] { + return s.mapToolName(val) + } + // ③ 签名/加密块:固定标记 + if signatureKeys[key] { + return "scrubbed-signature" + } + // ④ 行为相关字段:原样保留 + if behaviorKeys[key] { + return val + } + // ⑤ URL / 内联数据 + if isURLLike(key, val) { + return scrubURL(val) + } + // ⑥ 工具参数等「JSON 字符串」:保持其仍为合法 JSON + if key == "arguments" || key == "partial_json" { + return s.scrubEmbeddedJSON(val) + } + // ⑦ 凭证形态:无条件脱敏 + if secretPattern.MatchString(val) { + return filler(len(val)) + } + // ⑧ 裸 base64 载荷:图片/音频等二进制内联数据(Claude 的 source.data、Gemini 的 + // inlineData.data——不带 data: 前缀的形态)。等长文本填充有两个问题:体积随原图 + // 膨胀(实测单文件 1.2MB、语料总量 154MB),且填充含空格后不再是合法 base64, + // 图片处理路径会走进解码失败分支。换成可解码的 1×1 占位图(与 data: URL 同款): + // 内容对协议转换毫无影响(转换器只搬运 mimeType 与 data 字段),体积归一。 + if isBase64Payload(val) { + return placeholderPNG + } + // ⑨ 用户数据子树内一律脱敏;子树外用「短且无空格」启发式放过枚举值。 + // 超长文本按 MaxTextLen 截断填充(等长原则对量级敏感而非字节敏感,见选项说明) + if inUserData || !isEnumLike(key, val) { + return filler(s.cappedLen(len(val))) + } + return val +} + +// scrubEmbeddedJSON 脱敏「以字符串承载的 JSON」(tool arguments)。 +// +// 必须保持结果仍是合法 JSON:转换器会解析它,替换成普通文本会让转换走进 +// 解析失败分支,语料反映的就不再是线上行为。解析失败(如流式增量的半截 JSON) +// 时退化为等长填充。 +func (s *Scrubber) scrubEmbeddedJSON(val string) string { + var inner any + if err := json.Unmarshal([]byte(val), &inner); err != nil { + return filler(len(val)) + } + out, err := json.Marshal(s.scrubValue(inner, "", true)) + if err != nil { + return filler(len(val)) + } + return string(out) +} + +// mapID 标识符一致映射。保留已知前缀(call_ / toolu_ / msg_ 等)—— +// 部分测试与转换器按前缀模式识别合成 ID,改掉前缀会影响判定。 +func (s *Scrubber) mapID(orig string) string { + if mapped, ok := s.idMap[orig]; ok { + return mapped + } + mapped := fmt.Sprintf("%s%04d", idPrefixOf(orig), len(s.idMap)+1) + s.idMap[orig] = mapped + return mapped +} + +var knownIDPrefixes = []string{ + "srvtoolu_", "toolu_", "chatcmpl-", "call_", "msg_", "resp_", "rs_", + "fc_", "ws_", "item_", "req_", +} + +func idPrefixOf(orig string) string { + for _, p := range knownIDPrefixes { + if strings.HasPrefix(orig, p) { + return p + } + } + return "id_" +} + +// mapToolName 工具名一致映射。协议内置工具名原样保留——转换器按名识别服务端工具, +// 改掉会让语料不再触发 web_search 等能力分支。 +func (s *Scrubber) mapToolName(orig string) string { + if reservedToolNames[orig] { + return orig + } + if s.opts.KeepToolNames { + return orig + } + if mapped, ok := s.nameMap[orig]; ok { + return mapped + } + mapped := fmt.Sprintf("tool_%d", len(s.nameMap)+1) + s.nameMap[orig] = mapped + return mapped +} + +func isURLLike(key, val string) bool { + if key == "url" || key == "image_url" || key == "uri" || key == "fileUri" || key == "file_uri" { + return true + } + return strings.HasPrefix(val, "data:") || strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") +} + +// base64PayloadPattern 裸 base64 载荷形态(标准字母表 + 末尾可选填充)。 +var base64PayloadPattern = regexp.MustCompile(`^[A-Za-z0-9+/]+={0,2}$`) + +// base64PayloadMinLen 判定为二进制载荷的最小长度。真实图片/音频的 base64 动辄数千字符, +// 短于该值的串不可能是二进制载荷(也避免误伤碰巧只含 base64 字符的短文本/ID—— +// 它们仍走等长填充,长度语义不受影响)。 +const base64PayloadMinLen = 256 + +// isBase64Payload 判断值是否为裸 base64 二进制载荷(无 data: 前缀的内联数据形态)。 +func isBase64Payload(val string) bool { + return len(val) >= base64PayloadMinLen && base64PayloadPattern.MatchString(val) +} + +// scrubURL 脱敏 URL。 +// - data: URL 保留 mime 前缀、载荷换成可解码的 1×1 PNG(图片处理路径仍能工作) +// - http(s) URL 换成固定占位域名 +func scrubURL(val string) string { + if m := dataURLPattern.FindStringSubmatch(val); m != nil { + return m[1] + placeholderPNG + } + if strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://") { + return "https://scrubbed.example/resource" + } + return filler(len(val)) +} + +// filler 生成指定**字节长度**的占位文本。 +// +// 保留长度量级而非替换成固定短串:截断阈值、token 估算(2 字符/token)、 +// grounding 的字节偏移校正等逻辑都与长度相关,长度塌缩会让语料不再覆盖这些路径。 +func filler(n int) string { + if n <= 0 { + return "" + } + if n <= 3 { + return strings.Repeat("x", n) + } + const word = "scrubbed " + var sb strings.Builder + for sb.Len() < n { + sb.WriteString(word) + } + return sb.String()[:n] +} + +// cappedLen 应用 MaxTextLen 上限后的填充长度。 +func (s *Scrubber) cappedLen(n int) int { + if s.opts.MaxTextLen > 0 && n > s.opts.MaxTextLen { + return s.opts.MaxTextLen + } + return n +} diff --git a/internal/utility/corpus/scrub_test.go b/internal/utility/corpus/scrub_test.go new file mode 100644 index 0000000..d89d506 --- /dev/null +++ b/internal/utility/corpus/scrub_test.go @@ -0,0 +1,436 @@ +package corpus + +import ( + "encoding/base64" + "encoding/json" + "regexp" + "strings" + "testing" +) + +func scrubJSON(t *testing.T, body string, opts ...ScrubOptions) string { + t.Helper() + o := ScrubOptions{Enabled: true} + if len(opts) > 0 { + o = opts[0] + } + return string(NewScrubber(o).ScrubJSON([]byte(body))) +} + +// TestScrub_ToolCallIDReferenceConsistency 最关键的不变量: +// tool_call_id 在 assistant.tool_calls 与后续 tool_result 之间必须替换后仍相等。 +// +// 打断这条引用,工具循环链就断了——而工具循环恰是转换最容易出 bug 的地方, +// 语料反而会变成假阳性来源(测试报「工具循环丢失」,实际是语料自己坏了)。 +func TestScrub_ToolCallIDReferenceConsistency(t *testing.T) { + body := `{ + "messages": [ + {"role":"assistant","content":null,"tool_calls":[ + {"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Boston\"}"}}, + {"id":"call_def456","type":"function","function":{"name":"lookup_stock","arguments":"{\"symbol\":\"AAPL\"}"}} + ]}, + {"role":"tool","tool_call_id":"call_abc123","content":"68F"}, + {"role":"tool","tool_call_id":"call_def456","content":"180.5"} + ] + }` + out := scrubJSON(t, body) + + var parsed struct { + Messages []struct { + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("脱敏结果不是合法 JSON: %v\n%s", err, out) + } + + calls := parsed.Messages[0].ToolCalls + if len(calls) != 2 { + t.Fatalf("工具调用数变化: %+v", calls) + } + if calls[0].ID == "call_abc123" { + t.Error("标识符未被脱敏") + } + // 不同标识符不得映射成同一个值,否则两条工具结果会挂到同一个调用上 + if calls[0].ID == calls[1].ID { + t.Errorf("不同标识符被映射成同一个值: %s", calls[0].ID) + } + if parsed.Messages[1].ToolCallID != calls[0].ID { + t.Errorf("引用断裂: tool_call_id=%q 但 tool_calls[0].id=%q", + parsed.Messages[1].ToolCallID, calls[0].ID) + } + if parsed.Messages[2].ToolCallID != calls[1].ID { + t.Errorf("引用断裂: tool_call_id=%q 但 tool_calls[1].id=%q", + parsed.Messages[2].ToolCallID, calls[1].ID) + } +} + +// TestScrub_ClaudeToolUseReference Claude 用 tool_use.id ←→ tool_result.tool_use_id 关联。 +func TestScrub_ClaudeToolUseReference(t *testing.T) { + body := `{ + "messages": [ + {"role":"assistant","content":[{"type":"tool_use","id":"toolu_01X","name":"get_weather","input":{"city":"Boston"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01X","content":"68F"}]} + ] + }` + out := scrubJSON(t, body) + + if strings.Contains(out, "toolu_01X") { + t.Error("标识符未被脱敏") + } + // 前缀保留:部分转换器与测试按前缀模式识别合成 ID + ids := regexp.MustCompile(`toolu_\d+`).FindAllString(out, -1) + if len(ids) != 2 { + t.Fatalf("应保留 toolu_ 前缀且出现两次: %v\n%s", ids, out) + } + if ids[0] != ids[1] { + t.Errorf("Claude 工具引用断裂: %v", ids) + } +} + +// TestScrub_GeminiFunctionNameReference Gemini 的 functionCall ↔ functionResponse +// **靠函数名关联**(没有 id),因此工具名也必须一致映射。 +func TestScrub_GeminiFunctionNameReference(t *testing.T) { + body := `{ + "contents": [ + {"role":"model","parts":[{"functionCall":{"name":"query_internal_crm","args":{"id":"42"}}}]}, + {"role":"user","parts":[{"functionResponse":{"name":"query_internal_crm","response":{"result":"ok"}}}]} + ] + }` + out := scrubJSON(t, body) + + if strings.Contains(out, "query_internal_crm") { + t.Error("自定义工具名未被脱敏(可能泄漏业务逻辑)") + } + names := regexp.MustCompile(`"name":"([^"]+)"`).FindAllStringSubmatch(out, -1) + if len(names) != 2 { + t.Fatalf("函数名应出现两次: %v\n%s", names, out) + } + if names[0][1] != names[1][1] { + t.Errorf("Gemini 函数名引用断裂: %s vs %s", names[0][1], names[1][1]) + } +} + +// TestScrub_ToolArgumentsStayValidJSON tool arguments 是**JSON 字符串**, +// 替换后必须仍是合法 JSON——否则转换器走进解析失败分支,语料反映的不是线上行为。 +func TestScrub_ToolArgumentsStayValidJSON(t *testing.T) { + body := `{"tool_calls":[{"id":"call_1","function":{"name":"f","arguments":"{\"city\":\"Boston\",\"unit\":\"celsius\",\"days\":7}"}}]}` + out := scrubJSON(t, body) + + var parsed struct { + ToolCalls []struct { + Function struct { + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("外层不是合法 JSON: %v", err) + } + + args := parsed.ToolCalls[0].Function.Arguments + var inner map[string]any + if err := json.Unmarshal([]byte(args), &inner); err != nil { + t.Fatalf("arguments 脱敏后不再是合法 JSON: %v\n%s", err, args) + } + if _, ok := inner["city"]; !ok { + t.Errorf("arguments 的 key 结构应保留: %v", inner) + } + if inner["days"] != float64(7) { + t.Errorf("数值应保留(影响转换行为): %v", inner["days"]) + } + if inner["city"] == "Boston" { + t.Error("arguments 内的用户数据未被脱敏") + } +} + +// TestScrub_PreservesBehaviorFields 判别式与行为相关字段必须原样保留, +// 否则语料不再触发对应的转换分支(model 决定 Claude 默认 max_tokens 与 Vertex 端点选择)。 +func TestScrub_PreservesBehaviorFields(t *testing.T) { + body := `{ + "model":"claude-3-5-sonnet-20241022", + "max_tokens":1024, + "temperature":0.7, + "messages":[{"role":"user","content":[{"type":"text","text":"你好世界"}]}] + }` + out := scrubJSON(t, body) + + for _, must := range []string{ + `"model":"claude-3-5-sonnet-20241022"`, // 模型名影响分档与路由 + `"role":"user"`, // 角色是判别式 + `"type":"text"`, // 内容块类型是判别式 + `"max_tokens":1024`, // 数值影响转换 + } { + if !strings.Contains(out, must) { + t.Errorf("行为相关字段被破坏,缺少 %s\n%s", must, out) + } + } + if strings.Contains(out, "你好世界") { + t.Error("用户文本未被脱敏") + } +} + +// TestScrub_PreservesReservedToolNames 协议内置工具名必须保留—— +// 转换器按名识别服务端工具,改掉会让语料不再触发 web_search 等能力分支。 +func TestScrub_PreservesReservedToolNames(t *testing.T) { + body := `{"tools":[ + {"type":"web_search_20250305","name":"web_search","max_uses":5}, + {"name":"my_private_tool","description":"查询内部客户系统"} + ]}` + out := scrubJSON(t, body) + + if !strings.Contains(out, `"name":"web_search"`) { + t.Errorf("内置工具名必须保留: %s", out) + } + if !strings.Contains(out, `"type":"web_search_20250305"`) { + t.Errorf("内置工具类型必须保留: %s", out) + } + if strings.Contains(out, "my_private_tool") { + t.Error("自定义工具名应被替换") + } + if strings.Contains(out, "查询内部客户系统") { + t.Error("工具描述应被脱敏") + } +} + +// TestScrub_LengthPreserved 自由文本按**字节长度**等长替换: +// token 估算、截断阈值、grounding 字节偏移校正都与长度相关, +// 长度塌缩会让语料不再覆盖这些路径。 +func TestScrub_LengthPreserved(t *testing.T) { + original := "这是一段中文提示词,包含若干内容用于测试长度保留是否正确" + body := `{"messages":[{"role":"user","content":"` + original + `"}]}` + out := scrubJSON(t, body) + + var parsed struct { + Messages []struct { + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("不是合法 JSON: %v", err) + } + if got := len(parsed.Messages[0].Content); got != len(original) { + t.Errorf("脱敏后字节长度 = %d, want %d", got, len(original)) + } +} + +// TestScrub_Secrets 凭证形态无条件脱敏——用户完全可能把密钥粘进提示词。 +func TestScrub_Secrets(t *testing.T) { + for _, secret := range []string{ + "sk-proj-abcdefghijklmnopqrstuvwxyz123456", + "AIzaSyD-abcdefghijklmnopqrstuvwxyz12345", + "ghp_abcdefghijklmnopqrstuvwxyz1234567890", + } { + out := scrubJSON(t, `{"messages":[{"role":"user","content":"我的密钥是 `+secret+`"}]}`) + if strings.Contains(out, secret) { + t.Errorf("凭证未被脱敏: %s", secret) + } + } + // 即使出现在「短且无空格、看起来像枚举」的位置也要脱敏 + out := scrubJSON(t, `{"custom_field":"sk-proj-abcdefghijklmnopqrstuvwxyz123456"}`) + if strings.Contains(out, "sk-proj-abcdefghij") { + t.Errorf("枚举位置的凭证未被脱敏: %s", out) + } +} + +// TestScrub_SessionIdentifiers 会话级标识符必须脱敏:codex 把同一会话 UUID 塞进 +// 大量字段(含 x-codex-* 连字符 key 与任意自定义 key),key 名不可枚举—— +// 已知 key 走 idKeys 表,未知 key 靠裸 UUID 值形态兜底。同值字段映射后仍应相等。 +func TestScrub_SessionIdentifiers(t *testing.T) { + const sessionUUID = "01a0916c-85ba-7121-8819-8d582bfe20e6" + const installUUID = "aa11bb22-cc33-dd44-ee55-ff6677889900" + body := `{"session_id":"` + sessionUUID + `","thread_id":"` + sessionUUID + + `","turn_id":"` + sessionUUID + `","root_turn_id":"` + sessionUUID + + `","prompt_cache_key":"` + sessionUUID + + `","x-codex-window-id":"` + sessionUUID + `:0"` + + `,"x-codex-installation-id":"` + installUUID + + `","some_future_client_id_field":"` + installUUID + + `","model":"gpt-5"}` + + out := scrubJSON(t, body) + if strings.Contains(out, sessionUUID) || strings.Contains(out, installUUID) { + t.Errorf("会话/安装标识符未被脱敏: %s", out) + } + + var parsed struct { + SessionID string `json:"session_id"` + ThreadID string `json:"thread_id"` + TurnID string `json:"turn_id"` + Model string `json:"model"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("不是合法 JSON: %v", err) + } + if parsed.SessionID != parsed.ThreadID || parsed.SessionID != parsed.TurnID { + t.Errorf("同值字段映射后应仍相等: session=%s thread=%s turn=%s", + parsed.SessionID, parsed.ThreadID, parsed.TurnID) + } + if parsed.Model != "gpt-5" { + t.Errorf("行为字段 model 不应被改写: %s", parsed.Model) + } +} + +// TestScrub_InlineImageData 内联图片换成可解码的占位 PNG, +// data URL 的 mime 前缀保留(图片处理路径靠它判断格式)。 +func TestScrub_InlineImageData(t *testing.T) { + blob := strings.Repeat("QUJDREVG", 200) + body := `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,` + blob + `"}}]}]}` + out := scrubJSON(t, body) + + if strings.Contains(out, blob) { + t.Error("图片数据未被替换") + } + if !strings.Contains(out, "data:image/jpeg;base64,") { + t.Errorf("data URL 的 mime 前缀应保留: %s", out) + } + if !strings.Contains(out, placeholderPNG) { + t.Error("应替换为可解码的占位图片") + } + + out = scrubJSON(t, `{"image_url":{"url":"https://internal.corp.example/private/screenshot.png"}}`) + if strings.Contains(out, "internal.corp.example") { + t.Errorf("远程 URL 未被脱敏: %s", out) + } +} + +// TestScrub_Stream 流式脱敏保留事件名与帧结构,[DONE] 哨兵不动。 +func TestScrub_Stream(t *testing.T) { + stream := "event: content_block_delta\n" + + "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"敏感回答内容\"}}\n\n" + + "data: [DONE]\n\n" + out := string(NewScrubber(ScrubOptions{Enabled: true}).ScrubStream([]byte(stream))) + + if !strings.Contains(out, "event: content_block_delta") { + t.Errorf("事件名应保留: %s", out) + } + if !strings.Contains(out, `"type":"content_block_delta"`) { + t.Errorf("判别式字段应保留: %s", out) + } + if strings.Contains(out, "敏感回答内容") { + t.Error("流式文本未被脱敏") + } + if !strings.Contains(out, "data: [DONE]") { + t.Errorf("[DONE] 哨兵应原样保留(它是协议的一部分): %s", out) + } +} + +func TestScrub_Disabled(t *testing.T) { + body := `{"messages":[{"role":"user","content":"原文"}]}` + if got := scrubJSON(t, body, ScrubOptions{Enabled: false}); got != body { + t.Errorf("未启用脱敏应原样返回: %s", got) + } +} + +func TestScrub_KeepToolNames(t *testing.T) { + out := scrubJSON(t, `{"tools":[{"name":"my_tool"}]}`, ScrubOptions{Enabled: true, KeepToolNames: true}) + if !strings.Contains(out, "my_tool") { + t.Errorf("KeepToolNames=true 时应保留: %s", out) + } +} + +// TestScrub_SharedAcrossRequestAndResponse 同一记录的请求与响应共用 Scrubber, +// 标识符映射一致——否则两个语料文件之间的引用关系会对不上。 +func TestScrub_SharedAcrossRequestAndResponse(t *testing.T) { + r := Record{ + UpstreamStatus: 200, + ClientReqBody: []byte(`{"messages":[{"role":"tool","tool_call_id":"call_shared"}]}`), + UpstreamRespBody: []byte(`{"choices":[{"message":{"tool_calls":[{"id":"call_shared"}]}}]}`), + Conversion: Conversion{ClientFormat: "openai", UpstreamFormat: "openai"}, + } + samples := Build(r, NewScrubber(ScrubOptions{Enabled: true})) + if len(samples) != 2 { + t.Fatalf("应产出请求与响应两条语料: %d", len(samples)) + } + + ids := regexp.MustCompile(`call_\d+`) + reqID := ids.FindString(string(samples[0].Body)) + respID := ids.FindString(string(samples[1].Body)) + if reqID == "" || reqID != respID { + t.Errorf("请求与响应的同一标识符映射应一致: req=%q resp=%q", reqID, respID) + } +} + +// TestScrub_DedupStillWorks 脱敏后结构不变,因此同形状的记录仍应被去重。 +// 若脱敏破坏了结构(如把数组换成字符串),去重率会骤降——这是回归信号。 +func TestScrub_DedupStillWorks(t *testing.T) { + c := NewCollector(1, ScrubOptions{Enabled: true}) + c.Add(mkRecord(1, `{"messages":[{"role":"user","content":"第一个问题"}]}`, "", false)) + c.Add(mkRecord(2, `{"messages":[{"role":"user","content":"另一个完全不同的问题内容"}]}`, "", false)) + + if got := len(c.Samples()); got != 1 { + t.Errorf("脱敏后同形状仍应去重为 1 条,实际 %d", got) + } +} + +// TestScrub_CJKFreeText 短中文必须脱敏:中文通常**不含空格**,「短且无空格即枚举」 +// 的启发式对 CJK 失效,会把用户输入、思考内容原样放进语料。 +func TestScrub_CJKFreeText(t *testing.T) { + const zh = "先看仓库结构再改" + out := scrubJSON(t, `{"custom_field":"`+zh+`","finish_reason":"stop"}`) + if strings.Contains(out, zh) { + t.Errorf("短中文未被脱敏: %s", out) + } + var parsed struct { + CustomField string `json:"custom_field"` + FinishReason string `json:"finish_reason"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("不是合法 JSON: %v", err) + } + if parsed.FinishReason != "stop" { + t.Errorf("ASCII 枚举不应被误伤: %s", parsed.FinishReason) + } + if len(parsed.CustomField) != len(zh) { + t.Errorf("替换应等长: got %d want %d", len(parsed.CustomField), len(zh)) + } +} + +// TestScrub_RawBase64PayloadPlaceholder 裸 base64 内联数据(无 data: 前缀,如 Claude 的 +// source.data、Gemini 的 inlineData.data)必须换成占位图而非等长填充: +// 等长填充会让语料体积随原图膨胀(实测单文件 1.2MB),且空格混入后不再是合法 base64。 +func TestScrub_RawBase64PayloadPlaceholder(t *testing.T) { + // 构造 4KB 的 base64 形态载荷 + payload := strings.Repeat("QUJDREVG", 512) + body := `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + payload + `"}}` + out := scrubJSON(t, body) + + if strings.Contains(out, payload[:64]) { + t.Error("裸 base64 载荷未被替换") + } + if !strings.Contains(out, placeholderPNG) { + t.Error("应替换为可解码的占位图") + } + if len(out) > len(body)/4 { + t.Errorf("替换后体积应大幅缩小: got %d want < %d", len(out), len(body)/4) + } + // 占位图是合法 base64(图片处理路径依赖可解码性) + if _, err := base64.StdEncoding.DecodeString(placeholderPNG); err != nil { + t.Errorf("占位图不是合法 base64: %v", err) + } + // 长自由文本(含空格)不受影响,仍走等长填充 + long := strings.Repeat("这是一段比较长的中文文本内容。", 30) + out2 := scrubJSON(t, `{"note":"`+long+`"}`) + if !strings.Contains(out2, "scrubbed") || strings.Contains(out2, placeholderPNG) { + t.Error("长文本不应被误判为 base64 载荷") + } +} + +// TestScrub_MaxTextLen 超长文本按上限截断填充:携带完整会话历史的请求若不设上限, +// 语料会膨胀到数百 MB(实测),而长度敏感路径只需要量级不需精确字节。 +func TestScrub_MaxTextLen(t *testing.T) { + long := strings.Repeat("很长的内容", 2000) // 10KB + out := string(NewScrubber(ScrubOptions{Enabled: true, MaxTextLen: 128}).ScrubJSON( + []byte(`{"note":"` + long + `"}`))) + var parsed struct { + Note string `json:"note"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("不是合法 JSON: %v", err) + } + if len(parsed.Note) != 128 { + t.Errorf("填充长度 = %d, want 128(上限截断)", len(parsed.Note)) + } +} diff --git a/internal/utility/corpus/shape.go b/internal/utility/corpus/shape.go new file mode 100644 index 0000000..390bf9b --- /dev/null +++ b/internal/utility/corpus/shape.go @@ -0,0 +1,283 @@ +// Package corpus 从渠道调试日志提取协议转换测试语料。 +// +// 背景:协议转换的金样本/能力守恒/模糊测试此前全部由手写语料驱动,而手写语料的 +// 固有盲区正是「写的人想不到的形状」——真实客户端(Claude Code、Cursor、各家 SDK) +// 发出的请求远比人工构造的刁钻。chn_debug_logs 已经完整记录了四段报文, +// 本包负责把它转成金样本套件能直接消费的语料文件。 +// +// 关键取舍:真实流量冗余极大(万级记录往往只有几十种结构),直接落盘既臃肿又无益。 +// 因此以**结构指纹**去重,每种形状只留代表样本。指纹只用于去重判等, +// 其激进程度不影响落盘内容——语料始终是原文。 +package corpus + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "regexp" + "sort" + "strings" +) + +// freeTextKeys 这些 key 的字符串值是自由文本,对转换行为无影响, +// 计算指纹时一律折叠为类型占位——否则每条不同的用户输入都会算作一种新形状, +// 去重就完全失效了。 +// +// 注意 type / role / finish_reason 等判别式字段**不在**此列:它们决定转换分支, +// 是结构的一部分。 +var freeTextKeys = map[string]bool{ + "model": true, "text": true, "data": true, "url": true, "image_url": true, + "arguments": true, "partial_json": true, "content": true, "query": true, + "instructions": true, "system": true, "prompt": true, "uri": true, + "title": true, "output": true, "description": true, "name": true, + "id": true, "call_id": true, "tool_call_id": true, "tool_use_id": true, + "item_id": true, "response_id": true, "responseId": true, + "signature": true, "thought_signature": true, "thoughtSignature": true, + "encrypted_content": true, "page_age": true, "thinking": true, + "reasoning_content": true, "user": true, "metadata": true, + // 会话级标识符(codex 把同一会话 UUID 塞进 prompt_cache_key / session_id / + // thread_id / turn_id / root_turn_id / x-codex-* 镜像等大量字段):值随会话变化、 + // 与结构无关——不折叠的话跨会话的同形状请求会被判成不同形状,去重碎片化。 + // key 名无法穷举,裸 UUID 形态的值另由 uuidPattern 按值形态兜底 + "prompt_cache_key": true, "session_id": true, "thread_id": true, + "turn_id": true, "root_turn_id": true, + "x-codex-window-id": true, "x-codex-installation-id": true, +} + +// enumLikeMax 判定为枚举值的字符串长度上限(超过视为自由文本)。 +const enumLikeMax = 40 + +// uuidPattern 标准 UUID 形态(允许尾部 :N 轮次后缀,codex 的 x-codex-window-id 用)。 +// 会话/安装级标识符经常被客户端塞进任意命名的字段(prompt_cache_key、x-codex-* 镜像等), +// key 名不可枚举,只能按值形态识别——UUID 永远不是决定转换分支的判别式。 +var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(:[0-9]+)?$`) + +// bareHexIDPattern OpenAI 风格的 32 位十六进制 id(无连字符)。 +var bareHexIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`) + +// uuidContainsPattern 非锚定的 UUID 形态(用于对象 key 检测)。 +var uuidContainsPattern = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + +// isIDLikeKey 判断对象 key 是否为「按 id 索引」的动态 key。 +// Responses 的 usage 明细按 id 建 map(实测见过 msg_、at_、 +// 32 位十六进制等形态),id 由服务端逐次生成——原样进指纹的话这类报文 +// 每次都是新形状,去重永远失效。前缀不可枚举(msg_/at_/rs_/未来更多), +// 按「key 中包含完整 UUID」识别;正常字段名不会包含 UUID,不会误伤。 +func isIDLikeKey(k string) bool { + return uuidContainsPattern.MatchString(k) || bareHexIDPattern.MatchString(k) +} + +// Fingerprint 计算 JSON 体的结构指纹。非 JSON 返回 ok=false(流式语料走 FingerprintStream)。 +func Fingerprint(body []byte) (string, bool) { + var v any + if err := json.Unmarshal(body, &v); err != nil { + return "", false + } + return hashShape(shapeOf(v, "")), true +} + +// FingerprintStream 计算 SSE / NDJSON 流的结构指纹。 +// +// 逐帧取 data 负载的形状,再对帧形状序列做游程压缩——一个流有 3 个文本增量帧 +// 还是 300 个,结构上是同一件事,不折叠的话每条流都是独一无二的形状、去重失效。 +func FingerprintStream(body []byte) string { + frames := parseStreamFrames(body) + shapes := make([]any, 0, len(frames)) + for _, f := range frames { + var v any + if err := json.Unmarshal([]byte(f.data), &v); err != nil { + // 非 JSON 负载(如 [DONE] 哨兵)按原文入形状,它是协议的一部分 + shapes = append(shapes, map[string]any{"event": f.event, "raw": f.data}) + continue + } + shapes = append(shapes, map[string]any{"event": f.event, "data": shapeOf(v, "")}) + } + return hashShape(runLengthCollapse(shapes)) +} + +func hashShape(shape any) string { + raw, err := json.Marshal(shape) + if err != nil { + return "" + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// shapeOf 递归归一化为「结构」: +// - 对象:保留 key(排序后),值递归; +// - 数组:逐元素求形状后游程压缩,计数分桶(1/2/3/many); +// - 标量:判别式字符串保留原值,其余折叠为类型占位。 +// +// key 参数是该值所在的字段名,用于判断字符串是否为自由文本。 +func shapeOf(v any, key string) any { + switch t := v.(type) { + case map[string]any: + keys := make([]string, 0, len(t)) + for k := range t { + keys = append(keys, k) + } + sort.Strings(keys) + out := make(map[string]any, len(t)) + // 按 id 索引的动态 key(usage 明细等)折叠进同一个桶: + // 保留条目数量(分桶)与值的形状,抹掉 id 本身 + var idKeyed []any + for _, k := range keys { + if isIDLikeKey(k) { + idKeyed = append(idKeyed, shapeOf(t[k], k)) + continue + } + out[k] = shapeOf(t[k], k) + } + if idKeyed != nil { + out["#id-keyed"] = runLengthCollapse(idKeyed) + } + return out + + case []any: + if len(t) == 0 { + return []any{} + } + shapes := make([]any, 0, len(t)) + for _, el := range t { + shapes = append(shapes, shapeOf(el, key)) + } + return runLengthCollapse(shapes) + + case string: + if isEnumLike(key, t) { + return t + } + return "#str" + case float64: + return "#num" + case bool: + return "#bool" + case nil: + return "#null" + default: + return "#unknown" + } +} + +// isEnumLike 判断字符串值是否为判别式枚举(决定转换分支,须进指纹)。 +func isEnumLike(key, val string) bool { + if freeTextKeys[key] { + return false + } + // 裸 UUID 是客户端标识符而非枚举:不同会话的 UUID 各不相同, + // 当枚举保留会让跨会话的同形状请求指纹互异、去重碎片化 + if uuidPattern.MatchString(val) { + return false + } + // 含 CJK 等表意字符的值是用户内容而非枚举:协议判别式全是英文标识符, + // 而中文文本通常**不含空格**,「无空格即枚举」的启发式对 CJK 失效—— + // 不排除会把短中文(用户输入、思考内容)原样放进语料 + for _, r := range val { + if r >= 0x2E80 { + return false + } + } + if val == "" || len(val) > enumLikeMax { + return false + } + // 含空白即视为自然语言 + return !strings.ContainsAny(val, " \t\n\r") +} + +// runLengthCollapse 对形状序列做游程压缩:连续的相同形状折叠为 {shape, n}, +// n 按 1/2/3/many 分桶——区分「单条消息」与「多轮对话」这类结构差异, +// 同时把「50 轮」与「51 轮」这类长度噪音抹平。 +func runLengthCollapse(shapes []any) []any { + out := make([]any, 0, len(shapes)) + var lastKey string + count := 0 + + flush := func(shape any) { + if count == 0 { + return + } + out = append(out, map[string]any{"shape": shape, "n": bucketCount(count)}) + } + + var lastShape any + for _, s := range shapes { + raw, err := json.Marshal(s) + if err != nil { + continue + } + k := string(raw) + if count > 0 && k == lastKey { + count++ + continue + } + flush(lastShape) + lastShape, lastKey, count = s, k, 1 + } + flush(lastShape) + return out +} + +func bucketCount(n int) string { + switch { + case n <= 1: + return "1" + case n == 2: + return "2" + case n == 3: + return "3" + default: + return "many" + } +} + +// streamFrame 流中的一帧(SSE 事件名 + data 负载;NDJSON 的 event 为空)。 +type streamFrame struct { + event string + data string +} + +// parseStreamFrames 把 SSE 或 NDJSON 流拆成帧。 +// 含 "data:" 前缀行的按 SSE 解析(空行分帧),否则按 NDJSON 逐行解析。 +func parseStreamFrames(body []byte) []streamFrame { + text := string(body) + if !strings.Contains(text, "data:") { + return parseNDJSONFrames(text) + } + + var frames []streamFrame + for _, block := range strings.Split(text, "\n\n") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + var f streamFrame + var dataLines []string + for _, line := range strings.Split(block, "\n") { + switch { + case strings.HasPrefix(line, "event:"): + f.event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + if len(dataLines) == 0 && f.event == "" { + continue + } + f.data = strings.Join(dataLines, "\n") + frames = append(frames, f) + } + return frames +} + +func parseNDJSONFrames(text string) []streamFrame { + var frames []streamFrame + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + frames = append(frames, streamFrame{data: line}) + } + return frames +} diff --git a/internal/utility/corpus/shrink.go b/internal/utility/corpus/shrink.go new file mode 100644 index 0000000..d07eac2 --- /dev/null +++ b/internal/utility/corpus/shrink.go @@ -0,0 +1,35 @@ +package corpus + +import "regexp" + +// 语料体积控制。 +// +// 这不是脱敏:图片/音频的**内容**对协议转换毫无影响(转换器只搬运 mimeType 与 +// data 字段),但一张截图的 base64 动辄数 MB,原样落盘会让语料仓库迅速失控。 +// 替换为等价的最小合法数据,既保住「这里有一张图」的结构语义,又把体积压到常数。 + +// placeholderPNG 1×1 透明 PNG 的 base64,用作超长内联数据的替身。 +// 选真实可解码的图片而非任意字符串:图片处理路径(尺寸探测、格式校验) +// 拿到它仍能正常工作,语料不会因替身而走进异常分支。 +const placeholderPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + +// base64RunPattern 匹配连续的 base64 字符串。阈值由调用方给出, +// 在 8KB 量级上几乎不会误伤正常文本(自然语言与 JSON 结构都不会有这么长的无分隔串)。 +var base64RunPattern = regexp.MustCompile(`[A-Za-z0-9+/]{64,}={0,2}`) + +// ShrinkInlineData 把长度超过 maxBytes 的内联 base64 串替换为占位数据。 +// maxBytes <= 0 时原样返回。 +// +// 直接在文本层做替换而非解析 JSON:流式语料(SSE)不是单个 JSON, +// 且内联数据可能出现在任意嵌套位置,文本层处理对两种形态都适用。 +func ShrinkInlineData(body []byte, maxBytes int) []byte { + if maxBytes <= 0 || len(body) == 0 { + return body + } + return base64RunPattern.ReplaceAllFunc(body, func(match []byte) []byte { + if len(match) <= maxBytes { + return match + } + return []byte(placeholderPNG) + }) +} diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index 3b47fbb..cd73c56 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -33,7 +33,9 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { switch constant.RelayMode(info.RelayMode) { case constant.RelayModeClaudeMessages: return baseURL + "/apps/anthropic/v1/messages", nil - case constant.RelayModeChatCompletions: + // Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 compatible-mode chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return baseURL + "/compatible-mode/v1/chat/completions", nil case constant.RelayModeCompletions: return baseURL + "/compatible-mode/v1/completions", nil @@ -78,15 +80,21 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertMultimodalImageRequest(requestBody, info) } - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - c, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = c + processed, err := postProcessRequest(requestBody, info) + if err != nil { + return nil, err } + return bytes.NewReader(processed), nil +} +// PostProcessConvertedRequest 见 common.RequestPostProcessor: +// relaykit 完成格式转换后接回 DashScope 私有适配(按上游模型名剥离 thinking_budget)。 +func (a *Adaptor) PostProcessConvertedRequest(ctx context.Context, info *common.RelayInfo, body []byte) ([]byte, error) { + return postProcessRequest(body, info) +} + +// postProcessRequest DashScope 私有请求适配:请求体须已是 OpenAI chat 格式。 +func postProcessRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { // DashScope 参数适配(按上游模型名剥离不支持的 thinking_budget) converted, err := convertRequest(requestBody, info.ChannelMeta.UpstreamModelName) if err != nil { @@ -97,17 +105,17 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(converted, &rawMap); err != nil { - return bytes.NewReader(converted), nil + return converted, nil } rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) mapped, err := json.Marshal(rawMap) if err != nil { return nil, fmt.Errorf("marshal mapped request failed: %w", err) } - return bytes.NewReader(mapped), nil + return mapped, nil } - return bytes.NewReader(converted), nil + return converted, nil } func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { diff --git a/relay/channel/ali/post_process_test.go b/relay/channel/ali/post_process_test.go new file mode 100644 index 0000000..50db78f --- /dev/null +++ b/relay/channel/ali/post_process_test.go @@ -0,0 +1,8 @@ +package ali + +import ( + "github.com/qianfree/team-api/relay/common" +) + +// relaykit 接管转换后,私有请求适配靠这个可选接口接回;断言实现不掉。 +var _ common.RequestPostProcessor = (*Adaptor)(nil) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 5ffd592..92f768d 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -47,7 +47,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + // Claude/Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, + constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return baseURL + "/v1/chat/completions", nil case constant.RelayModeEmbeddings: return baseURL + "/v1/embeddings", nil @@ -65,15 +68,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。网关接受 OpenAI 格式,只需做模型名映射。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go index 4339881..d95a980 100644 --- a/relay/channel/baidu_v2/adaptor.go +++ b/relay/channel/baidu_v2/adaptor.go @@ -42,7 +42,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + // Claude/Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, + constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return baseURL + "/v2/chat/completions", nil case constant.RelayModeEmbeddings: return baseURL + "/v2/embeddings", nil @@ -68,50 +71,31 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。 // 如果模型名以 "-search" 结尾,去掉后缀并注入 web_search 配置。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted + processed, err := postProcessRequest(requestBody, info) + if err != nil { + return nil, err } + return bytes.NewReader(processed), nil +} +// postProcessRequest 百度请求适配:模型名映射。 +// 历史上还承担 -search 模型名后缀的剥离与 web_search 注入;该后缀语法已于 +// 2026-09 随全局虚拟模型后缀机制一并移除。模型映射已由 relaykit 覆盖, +// 故不再实现 RequestPostProcessor 钩子。 +func postProcessRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + if !info.ChannelMeta.IsModelMapped { + return requestBody, nil + } var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { - return bytes.NewReader(requestBody), nil + return requestBody, nil } - - // 确定上游模型名 - modelName := info.OriginModelName - if info.ChannelMeta.IsModelMapped { - modelName = info.ChannelMeta.UpstreamModelName - } - - // 检测 "-search" 后缀,启用搜索模式 - if strings.HasSuffix(modelName, "-search") { - modelName = strings.TrimSuffix(modelName, "-search") - webSearch := map[string]interface{}{ - "enable": true, - "enable_citation": true, - "enable_trace": true, - } - wsJSON, err := json.Marshal(webSearch) - if err != nil { - return nil, fmt.Errorf("marshal web_search failed: %w", err) - } - rawMap["web_search"] = json.RawMessage(wsJSON) - } - - // 设置模型名 - modelJSON, _ := json.Marshal(modelName) - rawMap["model"] = json.RawMessage(modelJSON) - + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) converted, err := json.Marshal(rawMap) if err != nil { return nil, fmt.Errorf("marshal converted request failed: %w", err) } - return bytes.NewReader(converted), nil + return converted, nil } func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 8119ee1..d0a33ca 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -32,8 +32,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { switch constant.RelayMode(info.RelayMode) { case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, + constant.RelayModeGeminiChat, constant.RelayModeResponses, constant.RelayModeResponsesCompact: - // Responses 入站:请求转 Claude Messages 格式打 /v1/messages,响应转回 Responses 格式 + // Responses/Gemini 入站:请求已由 relaykit 转为 Claude Messages 格式打 /v1/messages, + // 响应再转回客户端原生格式 return baseURL + "/v1/messages", nil default: return "", fmt.Errorf("unsupported relay mode for Claude: %d", info.RelayMode) @@ -74,41 +76,15 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 根据入站格式转换请求体为 Claude 格式 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - var converted io.Reader - switch info.InboundFormat { - case constant.RelayFormatClaude: - converted = bytes.NewReader(requestBody) - case constant.RelayFormatOpenAI: - r, err := ConvertOpenAIToClaude(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatGemini: - r, err := ConvertGeminiToClaude(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatResponses: - r, err := ConvertResponsesToClaude(requestBody, info) - if err != nil { - return nil, err - } - converted = r - default: - converted = bytes.NewReader(requestBody) + // OpenAI/Gemini/Responses 入站 → Claude 上游已由 relaykit 转换矩阵接管 + //(convertRequestBody 在 adaptor 之前完成转换),此处只剩 Claude 同格式路径 + //(直连判定未通过时:有模型映射/thinking 后缀/参数改写)。矩阵意外未接管时 + // 显式报错,避免把外格式请求体静默透传给 Anthropic 端点。 + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatClaude { + return nil, constant.NewChannelError(fmt.Sprintf("claude adaptor: relaykit converter unavailable for %s inbound", info.InboundFormat), nil) } - result := replaceModelIfNeeded(converted, info) - - // Thinking 后缀路由 - if info.ThinkingEnabled { - result = injectClaudeThinking(result, info) - } else if info.ReasoningEffort != "" { - result = injectClaudeEffort(result, info) - } - - return result, nil + converted := bytes.NewReader(requestBody) + return replaceModelIfNeeded(converted, info), nil } // DoRequest 发送请求到上游 @@ -143,67 +119,6 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request return resp, nil } -// injectClaudeThinking 注入 Claude thinking 配置(-thinking 后缀) -// 设 thinking.type=enabled, budget_tokens=80%*max_tokens, temperature=1.0 -func injectClaudeThinking(r io.Reader, info *common.RelayInfo) io.Reader { - body, err := io.ReadAll(r) - if err != nil { - return r - } - var rawMap map[string]json.RawMessage - if err := json.Unmarshal(body, &rawMap); err != nil { - return bytes.NewReader(body) - } - - // 获取 max_tokens - var maxTokens int - if mt, ok := rawMap["max_tokens"]; ok { - _ = json.Unmarshal(mt, &maxTokens) - } - if maxTokens < 1280 { - maxTokens = 16384 - } - budgetTokens := maxTokens * 80 / 100 - if budgetTokens < 1280 { - budgetTokens = 1280 - } - - // 设置 thinking - rawMap["thinking"] = json.RawMessage(fmt.Sprintf(`{"type":"enabled","budget_tokens":%d}`, budgetTokens)) - // Claude thinking 要求 temperature=1.0 - rawMap["temperature"] = json.RawMessage(`1.0`) - - result, err := json.Marshal(rawMap) - if err != nil { - return bytes.NewReader(body) - } - return bytes.NewReader(result) -} - -// injectClaudeEffort 注入 Claude effort 级别(-high/-low 等后缀) -// 使用 adaptive thinking 模式 -func injectClaudeEffort(r io.Reader, info *common.RelayInfo) io.Reader { - body, err := io.ReadAll(r) - if err != nil { - return r - } - var rawMap map[string]json.RawMessage - if err := json.Unmarshal(body, &rawMap); err != nil { - return bytes.NewReader(body) - } - - // 仅在客户端未显式设置 thinking 时注入 - if _, exists := rawMap["thinking"]; !exists { - rawMap["thinking"] = json.RawMessage(`{"type":"adaptive"}`) - } - - result, err := json.Marshal(rawMap) - if err != nil { - return bytes.NewReader(body) - } - return bytes.NewReader(result) -} - // DoResponse 处理上游响应,根据客户端格式分发 func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { clientFormat := info.GetOriginalClientFormat() @@ -222,12 +137,19 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com return a.handleStreamToOpenAI(ctx, resp, info, writer) } return a.handleNonStreamToOpenAI(ctx, resp, info, writer) - default: - // 兜底:默认 OpenAI 转换 + case constant.RelayFormatGemini: + // Gemini 入站(请求侧走 relaykit Gemini→Claude 转换):响应必须转回 Gemini 格式, + // 否则 Gemini SDK 拿到 OpenAI chunk 解析失败 if info.IsStream { - return a.handleStreamToOpenAI(ctx, resp, info, writer) + return a.handleStreamToGemini(ctx, resp, info, writer) } - return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + return a.handleNonStreamToGemini(ctx, resp, info, writer) + default: + // openai / claude / gemini / responses 四种入站格式均已显式处理 + //(见 relay_handler.go 的 relayModeToInboundFormat)。走到这里说明新增了客户端格式 + // 却漏接响应侧转换——显式失败,而不是静默按 OpenAI 格式写出让客户端 SDK 解析失败。 + return nil, constant.NewChannelError( + fmt.Sprintf("claude adaptor: unsupported client format %q", clientFormat), nil) } } diff --git a/relay/channel/claude/converter.go b/relay/channel/claude/converter.go deleted file mode 100644 index dfa0ba6..0000000 --- a/relay/channel/claude/converter.go +++ /dev/null @@ -1,446 +0,0 @@ -package claude - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/qianfree/team-api/relay/channel/openai" - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" -) - -// ConvertOpenAIToClaude 将 OpenAI 格式请求转换为 Claude Messages API 格式。 -func ConvertOpenAIToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - var openaiReq dto.GeneralOpenAIRequest - if err := json.Unmarshal(requestBody, &openaiReq); err != nil { - return nil, fmt.Errorf("parse openai request: %w", err) - } - - claudeReq := dto.ClaudeRequest{ - Model: info.ChannelMeta.UpstreamModelName, - Messages: make([]dto.ClaudeMessage, 0, len(openaiReq.Messages)), - } - - if openaiReq.MaxTokens != nil { - maxTokens := uint(*openaiReq.MaxTokens) - claudeReq.MaxTokens = &maxTokens - } else if openaiReq.MaxCompletionTokens != nil { - maxTokens := uint(*openaiReq.MaxCompletionTokens) - claudeReq.MaxTokens = &maxTokens - } else { - defaultMax := uint(4096) - claudeReq.MaxTokens = &defaultMax - } - - if openaiReq.Temperature != nil { - claudeReq.Temperature = openaiReq.Temperature - } - if openaiReq.TopP != nil { - claudeReq.TopP = openaiReq.TopP - } - if openaiReq.TopK != nil { - claudeReq.TopK = openaiReq.TopK - } - if openaiReq.Stop != nil { - if stops, ok := openaiReq.Stop.([]any); ok { - for _, s := range stops { - if str, ok := s.(string); ok { - claudeReq.StopSequences = append(claudeReq.StopSequences, str) - } - } - } else if str, ok := openaiReq.Stop.(string); ok { - claudeReq.StopSequences = append(claudeReq.StopSequences, str) - } - } - if openaiReq.Stream != nil { - claudeReq.Stream = openaiReq.Stream - } - - // reasoning_effort → thinking - if openaiReq.ReasoningEffort != "" { - claudeReq.Thinking = o2cConvertReasoningEffort(openaiReq.ReasoningEffort) - if claudeReq.MaxTokens != nil && *claudeReq.MaxTokens < 16384 { - v := uint(16384) - claudeReq.MaxTokens = &v - } - } - - if len(openaiReq.Tools) > 0 { - claudeReq.Tools = make([]dto.ClaudeTool, 0, len(openaiReq.Tools)) - for _, t := range openaiReq.Tools { - if t.Type == "function" { - claudeReq.Tools = append(claudeReq.Tools, dto.ClaudeTool{ - Name: t.Function.Name, - Description: t.Function.Description, - InputSchema: t.Function.Parameters, - }) - } - } - } - - claudeReq.ToolChoice = o2cConvertToolChoice(openaiReq.ToolChoice, openaiReq.ParallelToolCalls) - - var systemBlocks []dto.ClaudeContentBlock - var lastRole string - - for _, msg := range openaiReq.Messages { - content := msg.Content - if content == nil { - content = "" - } - - switch msg.Role { - case "system", "developer": - text := o2cExtractTextContent(content) - if text != "" { - systemBlocks = append(systemBlocks, dto.ClaudeContentBlock{ - Type: "text", - Text: &text, - }) - } - - case "user": - blocks := o2cConvertUserMessage(content) - if lastRole == "user" && len(claudeReq.Messages) > 0 { - lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] - lastMsg.Content = o2cMergeContents(lastMsg.Content, blocks) - } else { - claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ - Role: "user", - Content: blocks, - }) - } - lastRole = "user" - - case "assistant": - blocks := o2cConvertAssistantMessage(msg) - if lastRole == "assistant" && len(claudeReq.Messages) > 0 { - lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] - lastMsg.Content = o2cMergeContents(lastMsg.Content, blocks) - } else { - claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ - Role: "assistant", - Content: blocks, - }) - } - lastRole = "assistant" - - case "tool": - toolResult := dto.ClaudeContentBlock{ - Type: "tool_result", - ToolUseID: msg.ToolCallID, - Content: o2cExtractTextContent(content), - } - if lastRole == "user" && len(claudeReq.Messages) > 0 { - lastMsg := &claudeReq.Messages[len(claudeReq.Messages)-1] - lastMsg.Content = o2cMergeContents(lastMsg.Content, []dto.ClaudeContentBlock{toolResult}) - } else { - claudeReq.Messages = append(claudeReq.Messages, dto.ClaudeMessage{ - Role: "user", - Content: []dto.ClaudeContentBlock{toolResult}, - }) - } - lastRole = "user" - } - } - - if len(claudeReq.Messages) > 0 && claudeReq.Messages[0].Role != "user" { - claudeReq.Messages = append([]dto.ClaudeMessage{ - {Role: "user", Content: " "}, - }, claudeReq.Messages...) - } - - if len(systemBlocks) > 0 { - if len(systemBlocks) == 1 { - claudeReq.System = systemBlocks[0].Text - } else { - claudeReq.System = systemBlocks - } - } - - result, err := json.Marshal(claudeReq) - if err != nil { - return nil, fmt.Errorf("marshal claude request failed: %w", err) - } - - return bytes.NewReader(result), nil -} - -// ConvertGeminiToClaude 将 Gemini 格式请求转换为 Claude Messages API 格式。 -// 通过 OpenAI 作为中间格式:Gemini → OpenAI → Claude -func ConvertGeminiToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - openaiBody, err := openai.ConvertGeminiToOpenAI(requestBody, info) - if err != nil { - return nil, fmt.Errorf("gemini to openai conversion failed: %w", err) - } - openaiBytes, err := io.ReadAll(openaiBody) - if err != nil { - return nil, fmt.Errorf("read openai intermediate body failed: %w", err) - } - return ConvertOpenAIToClaude(openaiBytes, info) -} - -// ConvertResponsesToClaude 将 OpenAI Responses API 格式请求转换为 Claude Messages API 格式。 -// 通过 OpenAI 作为中间格式:Responses → OpenAI → Claude -func ConvertResponsesToClaude(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - openaiBody, err := openai.ConvertResponsesToOpenAI(requestBody, info) - if err != nil { - return nil, fmt.Errorf("responses to openai conversion failed: %w", err) - } - openaiBytes, err := io.ReadAll(openaiBody) - if err != nil { - return nil, fmt.Errorf("read openai intermediate body failed: %w", err) - } - return ConvertOpenAIToClaude(openaiBytes, info) -} - -func o2cConvertToolChoice(toolChoice any, parallelToolCalls *bool) any { - if toolChoice == nil { - if parallelToolCalls != nil && !*parallelToolCalls { - return map[string]any{ - "type": "auto", - "disable_parallel_tool_use": true, - } - } - return nil - } - - switch v := toolChoice.(type) { - case string: - switch v { - case "auto": - return map[string]any{"type": "auto"} - case "none": - return nil - case "required": - return map[string]any{"type": "any"} - } - case map[string]any: - if v["type"] == "function" { - if fn, ok := v["function"].(map[string]any); ok { - if name, ok := fn["name"].(string); ok { - return map[string]any{"type": "tool", "name": name} - } - } - } - } - return toolChoice -} - -func o2cMergeContents(existing any, newContent any) any { - existingBlocks := o2cContentToBlocks(existing) - newBlocks := o2cContentToBlocks(newContent) - merged := append(existingBlocks, newBlocks...) - if len(merged) == 1 && merged[0].Type == "text" { - return merged[0].Text - } - return merged -} - -func o2cContentToBlocks(content any) []dto.ClaudeContentBlock { - switch v := content.(type) { - case string: - if v == "" { - return nil - } - return []dto.ClaudeContentBlock{{Type: "text", Text: &v}} - case []dto.ClaudeContentBlock: - return v - default: - if content == nil { - return nil - } - return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(fmt.Sprintf("%v", v))}} - } -} - -func o2cExtractTextContent(content any) string { - switch v := content.(type) { - case string: - return v - case []any: - var parts []string - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if m["type"] == "text" { - if text, ok := m["text"].(string); ok { - parts = append(parts, text) - } - } - } - } - if len(parts) == 0 { - return "" - } - return o2cJoinTextParts(parts) - default: - return fmt.Sprintf("%v", v) - } -} - -func o2cConvertUserMessage(content any) any { - switch v := content.(type) { - case string: - if v == "" { - return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} - } - return v - case []any: - blocks := make([]dto.ClaudeContentBlock, 0, len(v)) - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - continue - } - switch m["type"] { - case "text": - if text, ok := m["text"].(string); ok { - blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &text}) - } - case "image_url": - if imageURL, ok := m["image_url"].(map[string]any); ok { - if url, ok := imageURL["url"].(string); ok { - if len(url) > 5 && url[:5] == "data:" { - block, ok := o2cParseDataURL(url) - if ok { - blocks = append(blocks, block) - continue - } - } - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - blocks = append(blocks, dto.ClaudeContentBlock{ - Type: "image", - Source: &dto.ClaudeSource{Type: "url", URL: url}, - }) - } else { - blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &url}) - } - } - } - } - } - if len(blocks) == 0 { - return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} - } - if len(blocks) == 1 && blocks[0].Type == "text" { - return blocks[0].Text - } - return blocks - default: - return fmt.Sprintf("%v", v) - } -} - -func o2cConvertAssistantMessage(msg dto.Message) any { - blocks := make([]dto.ClaudeContentBlock, 0) - - if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { - blocks = append(blocks, dto.ClaudeContentBlock{ - Type: "thinking", - Thinking: msg.ReasoningContent, - // 注意:Anthropic 官方要求历史中的 thinking 块必须带有有效签名。 - // 由于 OpenAI 格式没有专门的签名字段,此处留空。 - // 如果 API 拒绝该请求,将来可能需要存放在 Message.Annotations 或其它位置。 - }) - } - - text := o2cExtractTextContent(msg.Content) - if text != "" { - blocks = append(blocks, dto.ClaudeContentBlock{Type: "text", Text: &text}) - } - - for _, tc := range msg.ToolCalls { - var inputObj any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputObj); err != nil { - inputObj = map[string]any{} - } - blocks = append(blocks, dto.ClaudeContentBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Function.Name, - Input: inputObj, - }) - } - - if len(blocks) == 0 { - return []dto.ClaudeContentBlock{{Type: "text", Text: strPtr(" ")}} - } - if len(blocks) == 1 && blocks[0].Type == "text" { - return blocks[0].Text - } - return blocks -} - -func o2cParseDataURL(dataURL string) (dto.ClaudeContentBlock, bool) { - if len(dataURL) < 11 { - return dto.ClaudeContentBlock{}, false - } - rest := dataURL[5:] - semicolonIdx := -1 - for i := 0; i < len(rest); i++ { - if rest[i] == ';' { - semicolonIdx = i - break - } - } - if semicolonIdx == -1 { - return dto.ClaudeContentBlock{}, false - } - - mediaType := rest[:semicolonIdx] - afterSemicolon := rest[semicolonIdx+1:] - if len(afterSemicolon) < 7 || afterSemicolon[:7] != "base64," { - return dto.ClaudeContentBlock{}, false - } - data := afterSemicolon[7:] - - contentType := "image" - if mediaType == "application/pdf" { - contentType = "document" - } - - return dto.ClaudeContentBlock{ - Type: contentType, - Source: &dto.ClaudeSource{ - Type: "base64", - MediaType: mediaType, - Data: data, - }, - }, true -} - -func o2cJoinTextParts(parts []string) string { - result := make([]byte, 0, 64) - for i, p := range parts { - if i > 0 { - result = append(result, '\n') - } - result = append(result, p...) - } - return string(result) -} - -// o2cConvertReasoningEffort 将 OpenAI reasoning_effort 转换为 Claude thinking 配置 -func o2cConvertReasoningEffort(effort string) *dto.ClaudeThinking { - var budget int - switch effort { - case "low": - budget = 1024 - case "medium": - budget = 8192 - case "high": - budget = 32768 - default: - budget = 8192 - } - return &dto.ClaudeThinking{ - Type: "enabled", - BudgetTokens: &budget, - } -} - -// strPtr 返回 string 的指针 -func strPtr(v string) *string { return &v } diff --git a/relay/channel/claude/converter_test.go b/relay/channel/claude/converter_test.go deleted file mode 100644 index ecc2c51..0000000 --- a/relay/channel/claude/converter_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package claude - -import ( - "encoding/json" - "io" - "testing" - - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" -) - -func TestConvertOpenAIToClaude_SimpleChat(t *testing.T) { - info := &common.RelayInfo{ - ChannelMeta: &common.ChannelMeta{ - UpstreamModelName: "claude-3-5-sonnet-20241022", - }, - InboundFormat: "openai", - } - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "claude-3-5-sonnet-20241022", - Messages: []dto.Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hello"}, - {Role: "assistant", Content: "Hi there!"}, - {Role: "user", Content: "How are you?"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToClaude(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var claudeReq dto.ClaudeRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &claudeReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if claudeReq.Model != "claude-3-5-sonnet-20241022" { - t.Errorf("expected model claude-3-5-sonnet-20241022, got %s", claudeReq.Model) - } - if claudeReq.System != "You are helpful." { - t.Errorf("expected system 'You are helpful.', got %v", claudeReq.System) - } - if len(claudeReq.Messages) != 3 { - t.Fatalf("expected 3 messages, got %d", len(claudeReq.Messages)) - } - if claudeReq.Messages[0].Role != "user" { - t.Errorf("expected first message role 'user', got %s", claudeReq.Messages[0].Role) - } - if claudeReq.Messages[1].Role != "assistant" { - t.Errorf("expected second message role 'assistant', got %s", claudeReq.Messages[1].Role) - } - if claudeReq.Messages[2].Role != "user" { - t.Errorf("expected third message role 'user', got %s", claudeReq.Messages[2].Role) - } -} - -func TestConvertOpenAIToClaude_ToolUse(t *testing.T) { - info := &common.RelayInfo{ - ChannelMeta: &common.ChannelMeta{ - UpstreamModelName: "claude-3-5-sonnet-20241022", - }, - InboundFormat: "openai", - } - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "claude-3-5-sonnet-20241022", - Messages: []dto.Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - ToolCalls: []dto.ToolCall{ - { - ID: "call_123", - Type: "function", - Function: dto.FunctionCall{ - Name: "get_weather", - Arguments: `{"location":"NYC"}`, - }, - }, - }, - }, - {Role: "tool", ToolCallID: "call_123", Content: "Sunny, 72°F"}, - }, - Tools: []dto.Tool{ - { - Type: "function", - Function: dto.FunctionDef{ - Name: "get_weather", - Description: "Get weather info", - Parameters: map[string]any{"type": "object"}, - }, - }, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToClaude(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var claudeReq dto.ClaudeRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &claudeReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if len(claudeReq.Messages) != 3 { - t.Fatalf("expected 3 messages, got %d", len(claudeReq.Messages)) - } - assistantMsg := claudeReq.Messages[1] - if assistantMsg.Role != "assistant" { - t.Errorf("expected role 'assistant', got %s", assistantMsg.Role) - } - toolMsg := claudeReq.Messages[2] - if toolMsg.Role != "user" { - t.Errorf("expected role 'user' for tool result, got %s", toolMsg.Role) - } - if len(claudeReq.Tools) != 1 { - t.Fatalf("expected 1 tool, got %d", len(claudeReq.Tools)) - } - if claudeReq.Tools[0].Name != "get_weather" { - t.Errorf("expected tool name 'get_weather', got %s", claudeReq.Tools[0].Name) - } -} - -func TestClaudeStopReasonToOpenAI(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"end_turn", "stop"}, - {"stop_sequence", "stop"}, - {"max_tokens", "length"}, - {"tool_use", "tool_calls"}, - {"pause_turn", "stop"}, - {"refusal", "content_filter"}, - {"unknown", "unknown"}, - } - - for _, tt := range tests { - result := common.ClaudeStopReasonToOpenAI(tt.input) - if result != tt.expected { - t.Errorf("ClaudeStopReasonToOpenAI(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestConvertOpenAIToClaude_WithMaxTokens(t *testing.T) { - info := &common.RelayInfo{ - ChannelMeta: &common.ChannelMeta{ - UpstreamModelName: "claude-3-5-sonnet-20241022", - }, - InboundFormat: "openai", - } - - maxTokens := 2048 - openaiReq := dto.GeneralOpenAIRequest{ - Model: "claude-3-5-sonnet-20241022", - MaxTokens: &maxTokens, - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToClaude(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var claudeReq dto.ClaudeRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &claudeReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if claudeReq.MaxTokens == nil { - t.Fatal("expected MaxTokens to be set") - } - if *claudeReq.MaxTokens != 2048 { - t.Errorf("expected MaxTokens 2048, got %d", *claudeReq.MaxTokens) - } -} diff --git a/relay/channel/claude/gemini_bridge.go b/relay/channel/claude/gemini_bridge.go new file mode 100644 index 0000000..5306599 --- /dev/null +++ b/relay/channel/claude/gemini_bridge.go @@ -0,0 +1,84 @@ +package claude + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/relaykit_bridge" +) + +// ========== Gemini 入站桥接:Claude Messages → Gemini GenerateContent ========== +// +// 请求侧由 relaykit 的步骤链完成(Gemini → OpenAI 中枢 → Claude), +// 响应侧同样由 relaykit 转换器完成(Claude → Gemini,含 SSE 事件产出)。 +// 本文件只保留宿主侧接线:HTTP 状态码/错误处理、响应写出与计费用量提取。 +// +// 协议落差(thinking/redacted_thinking/签名往返、usage 口径换算等)已随转换逻辑 +// 一并迁入 relaykit(relaykit/relayconvert/internal/claude_gemini),此处不再重复描述。 +// +// 计费口径:写给客户端的是 Gemini 协议口径的 usage(由转换器产出), +// 而返回给上层计费的是 Claude 原生口径(input 不含缓存、cache_creation 独立计价), +// 因此非流式路径仍从**原始上游响应体**提取 usage,不取转换后的体。 + +// handleNonStreamToGemini 将 Claude 非流式响应转换为 Gemini 格式 +func (a *Adaptor) handleNonStreamToGemini(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + } + if resp.StatusCode != http.StatusOK { + // 不写响应:Gemini 入站由上层 WriteGeminiRelayError 统一写入,避免双写与重试时的响应污染 + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + } + + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("claude adaptor: no relaykit converter for gemini client response", nil) + } + if convErr != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", convErr).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(convertedBody) + + // 计费用量走 Claude 口径(input 不含缓存、cache_creation 独立计价), + // 与本适配器其他出站路径一致;上面写给客户端的是 Gemini 协议口径。 + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err != nil { + // Usage 解析失败,返回空 Usage(已写响应,不能重试;非致命错误) + return &common.Usage{}, nil + } + if claudeResp.Usage != nil { + return buildUsageFromClaude(claudeResp.Usage), nil + } + return &common.Usage{}, nil +} + +// handleStreamToGemini 将 Claude SSE 流转换为 Gemini SSE 流 +func (a *Adaptor) handleStreamToGemini(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // 不写响应:交上层 WriteGeminiRelayError 统一写入 + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + } + + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err + } + return nil, constant.NewChannelError("claude adaptor: relaykit stream converter unavailable for gemini client", nil) +} diff --git a/relay/channel/claude/gemini_bridge_test.go b/relay/channel/claude/gemini_bridge_test.go new file mode 100644 index 0000000..5e64d5a --- /dev/null +++ b/relay/channel/claude/gemini_bridge_test.go @@ -0,0 +1,375 @@ +package claude + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// geminiInboundInfo 构造 Gemini 入站 + Claude 上游渠道的 RelayInfo +func geminiInboundInfo(isStream bool) *common.RelayInfo { + return &common.RelayInfo{ + RelayMode: int(constant.RelayModeGeminiChat), + IsStream: isStream, + RequestID: "req123", + InboundFormat: constant.RelayFormatGemini, + ClientFormat: constant.RelayFormatGemini, + OriginModelName: "claude-sonnet-5", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "claude-sonnet-5", + }, + } +} + +func geminiJSONResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: geminiReadCloser(body), + } +} + +func geminiReadCloser(s string) *geminiNopCloser { + return &geminiNopCloser{Reader: strings.NewReader(s)} +} + +type geminiNopCloser struct{ *strings.Reader } + +func (n *geminiNopCloser) Close() error { return nil } + +// parseGeminiSSE 解析 Gemini 格式 SSE 输出(只有 data 行,无 event 行)为 chunk 列表。 +// 返回解析后的 chunk 与是否收到 [DONE]。 +func parseGeminiSSE(t *testing.T, raw string) ([]dto.GeminiChatResponse, bool) { + t.Helper() + var ( + chunks []dto.GeminiChatResponse + gotDone bool + frameSep = "\n\n" + ) + for frame := range strings.SplitSeq(raw, frameSep) { + frame = strings.TrimSpace(frame) + if frame == "" { + continue + } + payload, ok := strings.CutPrefix(frame, "data: ") + if !ok { + t.Fatalf("非法 SSE 帧(缺 data 前缀): %q", frame) + } + if payload == "[DONE]" { + gotDone = true + continue + } + var chunk dto.GeminiChatResponse + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + t.Fatalf("解析 Gemini chunk 失败: %v\ndata=%s", err, payload) + } + chunks = append(chunks, chunk) + } + return chunks, gotDone +} + +// collectParts 把所有 chunk 的 parts 按顺序摊平 +func collectParts(chunks []dto.GeminiChatResponse) []dto.GeminiPart { + var parts []dto.GeminiPart + for _, c := range chunks { + for _, cand := range c.Candidates { + if cand.Content != nil { + parts = append(parts, cand.Content.Parts...) + } + } + } + return parts +} + +// ===== 非流式 ===== + +// TestGetRequestURL_GeminiChatMode Gemini 入站(RelayModeGeminiChat)同样打 /v1/messages +// (请求侧已由 relaykit 转为 Claude Messages 格式,响应侧转回 Gemini)。 +// 回归:URL switch 漏加该模式时 DoRequest 直接报 unsupported relay mode。 +func TestGetRequestURL_GeminiChatMode(t *testing.T) { + a := &Adaptor{} + got, err := a.GetRequestURL(geminiInboundInfo(true)) + if err != nil { + t.Fatalf("GetRequestURL(GeminiChat) error: %v", err) + } + if want := "https://upstream.example.com/v1/messages"; got != want { + t.Errorf("GetRequestURL(GeminiChat) = %q, want %q", got, want) + } +} + +// TestHandleNonStreamToGemini_WritesGeminiBody 正常路径:响应体为 Gemini 结构, +// 计费用量仍为 Claude 口径(input 不含缓存)。 +func TestHandleNonStreamToGemini_WritesGeminiBody(t *testing.T) { + body := `{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5",` + + `"content":[{"type":"text","text":"你好"}],"stop_reason":"end_turn",` + + `"usage":{"input_tokens":40,"output_tokens":20,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}` + + a := &Adaptor{} + info := geminiInboundInfo(false) + rec := httptest.NewRecorder() + + usage, err := a.handleNonStreamToGemini(context.Background(), geminiJSONResponse(http.StatusOK, body), info, rec) + if err != nil { + t.Fatalf("handleNonStreamToGemini error: %v", err) + } + + var got dto.GeminiChatResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("响应体不是合法 Gemini JSON: %v\nbody=%s", err, rec.Body.String()) + } + if len(got.Candidates) != 1 || got.Candidates[0].Content == nil || + len(got.Candidates[0].Content.Parts) != 1 || got.Candidates[0].Content.Parts[0].Text != "你好" { + t.Errorf("内容不对: %+v", got.Candidates) + } + if got.Candidates[0].Content.Role != "model" { + t.Errorf("role = %q, want model", got.Candidates[0].Content.Role) + } + // 客户端可见用量为 Gemini 口径(prompt 含缓存) + if got.UsageMetadata.PromptTokenCount != 55 { + t.Errorf("客户端 promptTokenCount = %d, want 55", got.UsageMetadata.PromptTokenCount) + } + // 计费用量为 Claude 口径(prompt 不含缓存,cache_creation 独立) + if usage.PromptTokens != 40 { + t.Errorf("计费 PromptTokens = %d, want 40(Claude 口径不含缓存)", usage.PromptTokens) + } + if usage.CacheCreationTokens != 5 { + t.Errorf("计费 CacheCreationTokens = %d, want 5", usage.CacheCreationTokens) + } +} + +// TestHandleNonStreamToGemini_UpstreamErrorNotWritten 上游错误不在本层写响应, +// 交上层 WriteGeminiRelayError 统一写入。 +func TestHandleNonStreamToGemini_UpstreamErrorNotWritten(t *testing.T) { + a := &Adaptor{} + rec := httptest.NewRecorder() + body := `{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}` + + _, err := a.handleNonStreamToGemini(context.Background(), geminiJSONResponse(http.StatusTooManyRequests, body), geminiInboundInfo(false), rec) + + if err == nil { + t.Fatal("上游 429 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} + +// ===== 流式 ===== + +// TestHandleStreamToGemini_TextAndThinking 思考与正文分别映射为 thought part / 普通 part, +// 收尾 chunk 带 finishReason 与 usageMetadata,最后发 [DONE]。 +func TestHandleStreamToGemini_TextAndThinking(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":40,"output_tokens":0,"cache_read_input_tokens":10}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"想一下"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-xyz"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + ``, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你好"}}`, + ``, + `data: {"type":"content_block_stop","index":1}`, + ``, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":40,"output_tokens":20,"cache_read_input_tokens":10}}`, + ``, + `data: {"type":"message_stop"}`, + ``, + ``, + }, "\n") + + a := &Adaptor{} + info := geminiInboundInfo(true) + rec := httptest.NewRecorder() + + usage, err := a.handleStreamToGemini(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleStreamToGemini error: %v", err) + } + + chunks, gotDone := parseGeminiSSE(t, rec.Body.String()) + if gotDone { + t.Error("Gemini 客户端不得收到 [DONE](真实 Gemini API 无此哨兵,官方 SDK 对 data 帧做 JSON.parse 会崩溃)") + } + if len(chunks) < 2 { + t.Fatalf("chunk 数过少: %d", len(chunks)) + } + // 收尾 chunk 的 content 不得带 "parts": null(官方 SDK isValidContent 只判 undefined + // 后直接读 .length,null 会崩溃);键缺失或空数组均可 + if strings.Contains(rec.Body.String(), `"parts":null`) { + t.Error(`响应中出现 "parts":null,Gemini 官方 SDK 会崩溃`) + } + + parts := collectParts(chunks) + if len(parts) != 3 { + t.Fatalf("parts 数 = %d, want 3(thinking / signature / text): %+v", len(parts), parts) + } + if parts[0].Text != "想一下" || parts[0].Thought == nil || !*parts[0].Thought { + t.Errorf("思考 part 不对: %+v", parts[0]) + } + if parts[1].ThoughtSignature != "sig-xyz" { + t.Errorf("signature part 不对: %+v", parts[1]) + } + if parts[2].Text != "你好" || parts[2].Thought != nil { + t.Errorf("文本 part 不对: %+v", parts[2]) + } + + final := chunks[len(chunks)-1] + if final.Candidates[0].FinishReason != common.GeminiSTOP { + t.Errorf("收尾 finishReason = %q, want STOP", final.Candidates[0].FinishReason) + } + if final.UsageMetadata == nil || final.UsageMetadata.PromptTokenCount != 50 { + t.Errorf("收尾 usageMetadata 不对(应为 40+10 缓存): %+v", final.UsageMetadata) + } + + // 计费口径:Claude 的 input_tokens 不含缓存 + if usage.PromptTokens != 40 || usage.CompletionTokens != 20 { + t.Errorf("计费用量 = %d/%d, want 40/20", usage.PromptTokens, usage.CompletionTokens) + } +} + +// TestHandleStreamToGemini_ToolCallBuffered Claude 的 input_json_delta 分片必须 +// 缓冲拼接成完整对象后才能作为 functionCall.args 发出(Gemini 不接受分片)。 +func TestHandleStreamToGemini_ToolCallBuffered(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":10,"output_tokens":0}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_7","name":"get_weather","input":{}}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"北京\"}"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":10,"output_tokens":6}}`, + ``, + `data: {"type":"message_stop"}`, + ``, + ``, + }, "\n") + + a := &Adaptor{} + rec := httptest.NewRecorder() + + _, err := a.handleStreamToGemini(context.Background(), sseResponse(strings.NewReader(upstream)), geminiInboundInfo(true), rec) + if err != nil { + t.Fatalf("handleStreamToGemini error: %v", err) + } + + chunks, _ := parseGeminiSSE(t, rec.Body.String()) + parts := collectParts(chunks) + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("应恰好产出一个 functionCall part: %+v", parts) + } + + fc := parts[0].FunctionCall + if fc.FunctionName != "get_weather" || fc.ID != "toolu_7" { + t.Errorf("functionCall 元信息不对: %+v", fc) + } + args, ok := fc.Arguments.(map[string]any) + if !ok { + t.Fatalf("args 应为拼接后的对象, got %T: %v", fc.Arguments, fc.Arguments) + } + if args["city"] != "北京" { + t.Errorf("分片参数拼接错误: %+v", args) + } +} + +// TestHandleStreamToGemini_MalformedToolArgs 分片拼接后仍非法时降级为空对象, +// 保留函数名而不是整段丢弃或输出非法 JSON。 +func TestHandleStreamToGemini_MalformedToolArgs(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"broken"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + ``, + }, "\n") + + a := &Adaptor{} + rec := httptest.NewRecorder() + + _, err := a.handleStreamToGemini(context.Background(), sseResponse(strings.NewReader(upstream)), geminiInboundInfo(true), rec) + if err != nil { + t.Fatalf("handleStreamToGemini error: %v", err) + } + + chunks, _ := parseGeminiSSE(t, rec.Body.String()) + parts := collectParts(chunks) + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("应产出 functionCall part: %+v", parts) + } + if parts[0].FunctionCall.FunctionName != "broken" { + t.Errorf("函数名应保留: %+v", parts[0].FunctionCall) + } + if _, ok := parts[0].FunctionCall.Arguments.(map[string]any); !ok { + t.Errorf("非法参数应降级为空对象: %v", parts[0].FunctionCall.Arguments) + } +} + +// TestHandleStreamToGemini_UnclosedToolFlushed 上游异常断流、工具块未收到 +// content_block_stop 时兜底冲刷,避免整个调用丢失。 +func TestHandleStreamToGemini_UnclosedToolFlushed(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"half"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":1}"}}`, + ``, + ``, + }, "\n") + + a := &Adaptor{} + rec := httptest.NewRecorder() + + _, err := a.handleStreamToGemini(context.Background(), sseResponse(strings.NewReader(upstream)), geminiInboundInfo(true), rec) + if err != nil { + t.Fatalf("handleStreamToGemini error: %v", err) + } + + parts := collectParts(mustChunks(t, rec.Body.String())) + if len(parts) != 1 || parts[0].FunctionCall == nil || parts[0].FunctionCall.FunctionName != "half" { + t.Fatalf("未闭合的工具块应被兜底冲刷: %+v", parts) + } +} + +func mustChunks(t *testing.T, raw string) []dto.GeminiChatResponse { + t.Helper() + chunks, _ := parseGeminiSSE(t, raw) + return chunks +} + +// TestHandleStreamToGemini_UpstreamErrorNotWritten 流式上游错误同样不在本层写响应 +func TestHandleStreamToGemini_UpstreamErrorNotWritten(t *testing.T) { + a := &Adaptor{} + rec := httptest.NewRecorder() + resp := geminiJSONResponse(http.StatusServiceUnavailable, `{"type":"error","error":{"message":"down"}}`) + + _, err := a.handleStreamToGemini(context.Background(), resp, geminiInboundInfo(true), rec) + + if err == nil { + t.Fatal("上游 503 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} diff --git a/relay/channel/claude/response.go b/relay/channel/claude/response.go index ff82f49..c849526 100644 --- a/relay/channel/claude/response.go +++ b/relay/channel/claude/response.go @@ -32,56 +32,37 @@ func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Respon return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } - // relaykit 响应转换路径(特性开关控制,默认关闭)。失败/未启用回退旧代码路径。 - if convertedBody, _, ok := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body); ok { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(convertedBody) - - // relaykit 转换器返回的 Usage 为 nil(ResponseConverterFunc 签名约束),从原始 Claude 响应提取 - var claudeResp dto.ClaudeResponse - if err := json.Unmarshal(body, &claudeResp); err != nil { - // Usage 解析失败,返回空 Usage(已写响应,不能重试) - // 静默处理:非致命错误,响应已正确写入 - return &common.Usage{}, nil - } - if claudeResp.Usage != nil { - usage := &common.Usage{ - PromptTokens: claudeResp.Usage.InputTokens, - CompletionTokens: claudeResp.Usage.OutputTokens, - TotalTokens: claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens, - CacheCreationTokens: claudeResp.Usage.CacheCreationInputTokens, - PromptTokensDetails: claudeUsageToTokenDetails(claudeResp.Usage), - } - return usage, nil - } - // Usage 为 nil,返回空 Usage - return &common.Usage{}, nil + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("claude adaptor: no relaykit converter for openai client response", nil) } - - // 旧代码路径(relaykit 未启用或失败回退) - var claudeResp dto.ClaudeResponse - if err := json.Unmarshal(body, &claudeResp); err != nil { - return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + if convErr != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", convErr).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } - - // 转换为 OpenAI 格式 - openaiResp := claudeToOpenAIResponse(&claudeResp, info) - - respBody, _ := json.Marshal(openaiResp) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respBody) + _, _ = writer.Write(convertedBody) - usage := &common.Usage{} - if claudeResp.Usage != nil { - usage.PromptTokens = claudeResp.Usage.InputTokens - usage.CompletionTokens = claudeResp.Usage.OutputTokens - usage.TotalTokens = claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens - usage.CacheCreationTokens = claudeResp.Usage.CacheCreationInputTokens - usage.PromptTokensDetails = claudeUsageToTokenDetails(claudeResp.Usage) + // relaykit 转换器返回的 Usage 为 nil(ResponseConverterFunc 签名约束),从原始 Claude 响应提取 + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err != nil { + // Usage 解析失败,返回空 Usage(已写响应,不能重试) + // 静默处理:非致命错误,响应已正确写入 + return &common.Usage{}, nil } - return usage, nil + if claudeResp.Usage != nil { + usage := &common.Usage{ + PromptTokens: claudeResp.Usage.InputTokens, + CompletionTokens: claudeResp.Usage.OutputTokens, + TotalTokens: claudeResp.Usage.InputTokens + claudeResp.Usage.OutputTokens, + CacheCreationTokens: claudeResp.Usage.CacheCreationInputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(claudeResp.Usage), + } + return usage, nil + } + // Usage 为 nil,返回空 Usage + return &common.Usage{}, nil } // handleStreamToOpenAI 将 Claude 流式响应转换为 OpenAI SSE 格式 @@ -93,286 +74,14 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response, return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } - // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。 - if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { - // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算 - if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { - return usage, common.ErrStreamInterrupted - } - return usage, nil + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - responseID := fmt.Sprintf("chatcmpl-%s", info.RequestID) - createdAt := time.Now().Unix() - - var ( - usage dto.ClaudeUsage - modelName string - finishReason string - toolCallIdx int - roleChunkSent bool - responseTextBuf strings.Builder - ) - - newChunk := func(delta dto.Message) *dto.ChatCompletionStreamResponse { - m := modelName - if m == "" { - m = info.OriginModelName - } - return &dto.ChatCompletionStreamResponse{ - ID: responseID, - Object: "chat.completion.chunk", - Created: createdAt, - Model: m, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: delta, - }}, - } - } - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - interruptedUsage := buildUsageFromClaude(&usage) - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, responseTextBuf.Len()) - return interruptedUsage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" { - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - - data, _ := helper.ExtractSSEData(line) - - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } - - var event dto.ClaudeResponse - if err := json.Unmarshal([]byte(data), &event); err != nil { - // JSON 解析失败:静默跳过(允许部分格式异常) - continue - } - - switch event.Type { - case "message_start": - if event.Message != nil { - modelName = event.Message.Model - if modelName == "" { - modelName = info.OriginModelName - } - if event.Message.Usage != nil { - usage = *event.Message.Usage - } - } - - if !roleChunkSent { - emptyContent := "" - writeStreamChunk(writer, newChunk(dto.Message{ - Role: "assistant", - Content: &emptyContent, - })) - roleChunkSent = true - } - - case "content_block_start": - if event.ContentBlock == nil { - continue - } - switch event.ContentBlock.Type { - case "text": - case "thinking": - case "redacted_thinking": - // 脱敏思考,OpenAI 格式无等价物,忽略 - case "tool_use": - toolCall := dto.ToolCall{ - Index: toolCallIdx, - ID: event.ContentBlock.ID, - Type: "function", - Function: dto.FunctionCall{ - Name: event.ContentBlock.Name, - Arguments: "", - }, - } - writeStreamChunk(writer, newChunk(dto.Message{ - ToolCalls: []dto.ToolCall{toolCall}, - })) - toolCallIdx++ - } - - case "content_block_delta": - if event.Delta == nil { - continue - } - switch event.Delta.Type { - case "text_delta": - if event.Delta.Text != nil && *event.Delta.Text != "" { - responseTextBuf.WriteString(*event.Delta.Text) - writeStreamChunk(writer, newChunk(dto.Message{ - Content: *event.Delta.Text, - })) - } - case "thinking_delta": - if event.Delta.Thinking != nil && *event.Delta.Thinking != "" { - writeStreamChunk(writer, newChunk(dto.Message{ - ReasoningContent: event.Delta.Thinking, - })) - } - case "input_json_delta": - if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" { - writeStreamChunk(writer, newChunk(dto.Message{ - ToolCalls: []dto.ToolCall{{ - Index: toolCallIdx - 1, - Function: dto.FunctionCall{ - Arguments: *event.Delta.PartialJSON, - }, - }}, - })) - } - case "signature_delta": - } - - case "content_block_stop": - - case "error": - errMsg := "claude stream error" - if event.Error != nil { - if b, err := json.Marshal(event.Error); err == nil { - errMsg = fmt.Sprintf("claude stream error: %s", string(b)) - } - } - info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("%s", errMsg)) - - case "message_delta": - if event.Delta != nil { - if event.Delta.StopReason != nil { - finishReason = common.ClaudeStopReasonToOpenAI(*event.Delta.StopReason) - } - } - if event.Usage != nil { - if event.Usage.InputTokens > 0 { - usage.InputTokens = event.Usage.InputTokens - } - usage.OutputTokens = event.Usage.OutputTokens - if event.Usage.CacheReadInputTokens > 0 { - usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens - } - if event.Usage.CacheCreationInputTokens > 0 { - usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens - } - if event.Usage.CacheCreation != nil { - usage.CacheCreation = event.Usage.CacheCreation - } - } - - case "message_stop": - reason := finishReason - if reason == "" { - reason = "stop" - } - // Claude 的 input_tokens 不含缓存(三项并列),OpenAI 的 prompt_tokens 含缓存 - //(cached 是其子集),客户端可见 usage 做加法;计费返回值另行按 Claude 口径构建 - promptTotal := usage.InputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens - usageObj := &dto.UsageWithDetails{ - PromptTokens: promptTotal, - CompletionTokens: usage.OutputTokens, - TotalTokens: promptTotal + usage.OutputTokens, - PromptTokensDetails: common.CommonTokenDetailsToDto(claudeUsageToTokenDetails(&usage)), - } - if usageObj.CompletionTokens == 0 { - estimated := responseTextBuf.Len() / 4 - if estimated > 0 { - usageObj.CompletionTokens = estimated - usageObj.TotalTokens = usageObj.PromptTokens + usageObj.CompletionTokens - } - } - - chunk := &dto.ChatCompletionStreamResponse{ - ID: responseID, - Object: "chat.completion.chunk", - Created: createdAt, - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - FinishReason: &reason, - }}, - Usage: usageObj, - } - if chunk.Model == "" { - chunk.Model = info.OriginModelName - } - writeStreamChunk(writer, chunk) - helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - } - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return &common.Usage{}, fmt.Errorf("stream scanner error: %w", err) - } - - if info.StreamStatus.GetEndReason() == "" { - reason := "stop" - if finishReason != "" { - reason = finishReason - } - usageObj := &dto.UsageWithDetails{ - PromptTokens: usage.InputTokens, - CompletionTokens: usage.OutputTokens, - TotalTokens: usage.InputTokens + usage.OutputTokens, - PromptTokensDetails: common.CommonTokenDetailsToDto(claudeUsageToTokenDetails(&usage)), - } - if usageObj.CompletionTokens == 0 { - estimated := responseTextBuf.Len() / 4 - if estimated > 0 { - usageObj.CompletionTokens = estimated - usageObj.TotalTokens = usageObj.PromptTokens + usageObj.CompletionTokens - } - } - chunk := &dto.ChatCompletionStreamResponse{ - ID: responseID, - Object: "chat.completion.chunk", - Created: createdAt, - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - FinishReason: &reason, - }}, - Usage: usageObj, - } - if chunk.Model == "" { - chunk.Model = info.OriginModelName - } - writeStreamChunk(writer, chunk) - helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - - return &common.Usage{ - PromptTokens: usage.InputTokens, - CompletionTokens: usage.OutputTokens, - TotalTokens: usage.InputTokens + usage.OutputTokens, - CacheCreationTokens: usage.CacheCreationInputTokens, - PromptTokensDetails: claudeUsageToTokenDetails(&usage), - }, nil + return nil, constant.NewChannelError("claude adaptor: relaykit stream converter unavailable for openai client", nil) } // handleClaudeNativeResponse 直通 Claude 原生格式响应 diff --git a/relay/channel/claude/responses_bridge.go b/relay/channel/claude/responses_bridge.go index 3ce134b..8ad0870 100644 --- a/relay/channel/claude/responses_bridge.go +++ b/relay/channel/claude/responses_bridge.go @@ -1,37 +1,26 @@ package claude import ( - "bufio" "context" "encoding/json" - "fmt" "io" "net/http" - "strings" - "time" - "github.com/gogf/gf/v2/frame/g" - - "github.com/qianfree/team-api/relay/channel/openai" "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" ) // ========== Responses 入站桥接:Claude Messages → OpenAI Responses ========== // -// 请求侧由 ConvertResponsesToClaude 完成(Responses → OpenAI → Claude), -// 这里做响应侧:把 Claude 上游的响应/SSE 转回 Responses 格式, -// 事件发射器复用 openai 包(EmitResponsesSSE / BuildResponsesObjectMap), -// 与 chat→responses 桥接(openai/responses.go)的事件序列保持一致。 - -// claudeToolCallState Responses 桥接中聚合中的工具调用(Claude tool_use 块) -type claudeToolCallState struct { - id string - name string - args strings.Builder -} +// 请求侧由 relaykit 的步骤链完成(Responses → OpenAI 中枢 → Claude), +// 响应侧同样由 relaykit 转换器完成(Claude → Responses,含 SSE 事件序列产出)。 +// 本文件只保留宿主侧接线:HTTP 状态码/错误处理、响应写出与计费用量提取。 +// +// 计费口径:写给客户端的 usage 为 OpenAI 语义(input 含缓存,cached 为子集,由转换器产出), +// 返回给上层计费的是 Claude 原生口径(input 不含缓存、cache 独立列), +// 因此非流式路径仍从**原始上游响应体**提取 usage。 // handleNonStreamToResponses 将 Claude 非流式响应转换为 Responses 格式 func (a *Adaptor) handleNonStreamToResponses(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { @@ -45,455 +34,45 @@ func (a *Adaptor) handleNonStreamToResponses(ctx context.Context, resp *http.Res return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } - var claudeResp dto.ClaudeResponse - if err := json.Unmarshal(body, &claudeResp); err != nil { - return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("claude adaptor: no relaykit converter for responses client response", nil) } - - // 构建 output:文本块 → message 项,tool_use 块 → function_call 项 - var textParts []string - output := make([]map[string]any, 0) - for _, block := range claudeResp.Content { - switch block.Type { - case "text": - if block.Text != nil && *block.Text != "" { - textParts = append(textParts, *block.Text) - } - case "thinking", "redacted_thinking": - // 思考内容无 Responses 非流式对应物,跳过(流式侧以 reasoning summary 事件透出) - case "tool_use": - argsJSON, _ := json.Marshal(block.Input) - output = append(output, map[string]any{ - "type": "function_call", - "id": block.ID, - "call_id": block.ID, - "name": block.Name, - "arguments": string(argsJSON), - "status": "completed", - }) - } - } - if len(textParts) > 0 { - msgItem := map[string]any{ - "type": "message", - "id": fmt.Sprintf("msg_%s", claudeResp.ID), - "status": "completed", - "role": "assistant", - "content": []map[string]any{{ - "type": "output_text", - "text": strings.Join(textParts, "\n"), - "annotations": []any{}, - }}, - } - output = append([]map[string]any{msgItem}, output...) - } - - modelName := claudeResp.Model - if modelName == "" || info.ChannelMeta.IsModelMapped { - modelName = info.OriginModelName - } - respID := fmt.Sprintf("resp_%s", claudeResp.ID) - if claudeResp.ID == "" { - respID = fmt.Sprintf("resp_%d", time.Now().UnixNano()) - } - createdAt := int(time.Now().Unix()) - completedAt := createdAt - - // 客户端可见 usage 用 OpenAI 语义(input 含缓存,cached 为子集) - var visibleUsage *common.Usage - if claudeResp.Usage != nil { - promptTotal := claudeResp.Usage.InputTokens + - claudeResp.Usage.CacheReadInputTokens + - claudeResp.Usage.CacheCreationInputTokens - visibleUsage = &common.Usage{ - PromptTokens: promptTotal, - CompletionTokens: claudeResp.Usage.OutputTokens, - TotalTokens: promptTotal + claudeResp.Usage.OutputTokens, - PromptTokensDetails: claudeUsageToTokenDetails(claudeResp.Usage), - } - } else { - visibleUsage = &common.Usage{} - } - - responsesBody, err := json.Marshal(openai.BuildResponsesObjectMap(respID, createdAt, "completed", modelName, output, openai.BuildResponsesUsageMap(visibleUsage), &completedAt, info)) - if err != nil { - return nil, fmt.Errorf("marshal responses body failed: %w", err) + if convErr != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", convErr).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(responsesBody) + _, _ = writer.Write(convertedBody) // 计费返回值按 Claude 口径(input 不含缓存,cache 独立列) - return buildUsageFromClaude(claudeResp.Usage), nil + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err != nil { + // Usage 解析失败,返回空 Usage(已写响应,不能重试;非致命错误) + return &common.Usage{}, nil + } + if claudeResp.Usage != nil { + return buildUsageFromClaude(claudeResp.Usage), nil + } + return &common.Usage{}, nil } // handleStreamToResponses 将 Claude 流式响应转换为 Responses 格式的 SSE func (a *Adaptor) handleStreamToResponses(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) - } defer resp.Body.Close() - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - respID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) - msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) - createdAt := int(time.Now().Unix()) - modelName := info.OriginModelName - - var usage dto.ClaudeUsage - var textBuf strings.Builder - sentCreated := false - sentTextDone := false - sentCompleted := false - outputIndex := 0 - contentIndex := 0 - toolCalls := make([]*claudeToolCallState, 0) // 有序聚合,completed 的 output 数组按此顺序 - toolIndexByID := make(map[string]int) // callID → output_index - var currentTool *claudeToolCallState // 正在接收参数增量的工具调用 - - // buildBillingUsage 计费口径(Claude:input 不含缓存),输出缺失按已转发文本估算 - buildBillingUsage := func() *common.Usage { - u := buildUsageFromClaude(&usage) - if u.CompletionTokens == 0 && textBuf.Len() > 0 { - u.CompletionTokens = helper.EstimateStreamOutputTokens(info, textBuf.Len()) - u.TotalTokens = u.PromptTokens + u.CompletionTokens - } - return u + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } - // closeTextPart 关闭文本 content part(进入工具调用或流结束时调用) - closeTextPart := func() { - if sentTextDone { - return - } - finishedText := textBuf.String() - openai.EmitResponsesSSE(writer, "response.output_text.done", map[string]any{ - "type": "response.output_text.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "text": finishedText, - }) - openai.EmitResponsesSSE(writer, "response.content_part.done", map[string]any{ - "type": "response.content_part.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]any{ - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }, - }) - openai.EmitResponsesSSE(writer, "response.output_item.done", map[string]any{ - "type": "response.output_item.done", - "output_index": outputIndex, - "item": map[string]any{ - "type": "message", - "id": msgID, - "status": "completed", - "role": "assistant", - "content": []map[string]any{{ - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }}, - }, - }) - sentTextDone = true - outputIndex++ - } - - // finish 发送每个工具调用的收尾事件 + response.completed - finish := func() { - if sentCompleted { - return - } - closeTextPart() - - // 输出数组:文本消息(closeTextPart 已置 sentTextDone,此处等价于有文本才保留, - // 与 chat→responses 桥接的 finalOutput 构建口径一致) - finalOutput := make([]map[string]any, 0) - if textBuf.Len() > 0 { - finalOutput = append(finalOutput, map[string]any{ - "type": "message", - "id": msgID, - "status": "completed", - "role": "assistant", - "content": []map[string]any{{ - "type": "output_text", - "text": textBuf.String(), - "annotations": []any{}, - }}, - }) - } - - for _, tc := range toolCalls { - openai.EmitResponsesSSE(writer, "response.function_call_arguments.done", map[string]any{ - "type": "response.function_call_arguments.done", - "item_id": tc.id, - "output_index": toolIndexByID[tc.id], - "arguments": tc.args.String(), - }) - openai.EmitResponsesSSE(writer, "response.output_item.done", map[string]any{ - "type": "response.output_item.done", - "output_index": toolIndexByID[tc.id], - "item": map[string]any{ - "type": "function_call", - "id": tc.id, - "call_id": tc.id, - "name": tc.name, - "arguments": tc.args.String(), - "status": "completed", - }, - }) - finalOutput = append(finalOutput, map[string]any{ - "type": "function_call", - "id": tc.id, - "call_id": tc.id, - "name": tc.name, - "arguments": tc.args.String(), - "status": "completed", - }) - } - - // 客户端可见 usage 用 OpenAI 语义(input 含缓存,cached 为子集) - promptTotal := usage.InputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens - visibleUsage := &common.Usage{ - PromptTokens: promptTotal, - CompletionTokens: usage.OutputTokens, - TotalTokens: promptTotal + usage.OutputTokens, - PromptTokensDetails: claudeUsageToTokenDetails(&usage), - } - completedAt := int(time.Now().Unix()) - openai.EmitResponsesSSE(writer, "response.completed", map[string]any{ - "type": "response.completed", - "response": openai.BuildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, openai.BuildResponsesUsageMap(visibleUsage), &completedAt, info), - }) - sentCompleted = true - - if info.StreamStatus.GetEndReason() == "" { - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - } - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - interruptedUsage := buildUsageFromClaude(&usage) - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, textBuf.Len()) - return interruptedUsage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" || !strings.HasPrefix(line, "data:") { - continue - } - data, _ := helper.ExtractSSEData(line) - if data == "" || data == "[DONE]" { - continue - } - info.SetFirstResponseTime() - - var event dto.ClaudeResponse - if err := json.Unmarshal([]byte(data), &event); err != nil { - continue - } - - switch event.Type { - case "message_start": - if event.Message != nil { - if event.Message.Model != "" && !info.ChannelMeta.IsModelMapped { - modelName = event.Message.Model - } - if event.Message.Usage != nil { - usage = *event.Message.Usage - } - } - if sentCreated { - continue - } - openai.EmitResponsesSSE(writer, "response.created", map[string]any{ - "type": "response.created", - "response": openai.BuildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, info), - }) - openai.EmitResponsesSSE(writer, "response.output_item.added", map[string]any{ - "type": "response.output_item.added", - "output_index": outputIndex, - "item": map[string]any{ - "type": "message", - "id": msgID, - "status": "in_progress", - "role": "assistant", - "content": []any{}, - }, - }) - openai.EmitResponsesSSE(writer, "response.content_part.added", map[string]any{ - "type": "response.content_part.added", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]any{ - "type": "output_text", - "text": "", - "annotations": []any{}, - }, - }) - sentCreated = true - - case "content_block_start": - if event.ContentBlock == nil { - continue - } - switch event.ContentBlock.Type { - case "tool_use": - // 先关闭文本 content part,再开 function_call 项 - closeTextPart() - tc := &claudeToolCallState{id: event.ContentBlock.ID, name: event.ContentBlock.Name} - toolCalls = append(toolCalls, tc) - toolIndexByID[tc.id] = outputIndex - currentTool = tc - openai.EmitResponsesSSE(writer, "response.output_item.added", map[string]any{ - "type": "response.output_item.added", - "output_index": outputIndex, - "item": map[string]any{ - "type": "function_call", - "id": tc.id, - "call_id": tc.id, - "name": tc.name, - "status": "in_progress", - }, - }) - outputIndex++ - case "text", "thinking", "redacted_thinking": - // 文本/思考块:文本复用首个 content part,思考以 reasoning summary 事件透出 - } - - case "content_block_delta": - if event.Delta == nil { - continue - } - switch event.Delta.Type { - case "text_delta": - if event.Delta.Text != nil && *event.Delta.Text != "" { - textBuf.WriteString(*event.Delta.Text) - openai.EmitResponsesSSE(writer, "response.output_text.delta", map[string]any{ - "type": "response.output_text.delta", - "item_id": msgID, - "output_index": 0, - "content_index": contentIndex, - "delta": *event.Delta.Text, - }) - } - case "thinking_delta": - if event.Delta.Thinking != nil && *event.Delta.Thinking != "" { - openai.EmitResponsesSSE(writer, "response.reasoning_summary_text.delta", map[string]any{ - "type": "response.reasoning_summary_text.delta", - "item_id": msgID, - "output_index": 0, - "summary_index": 0, - "delta": *event.Delta.Thinking, - }) - } - case "input_json_delta": - if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" && currentTool != nil { - currentTool.args.WriteString(*event.Delta.PartialJSON) - openai.EmitResponsesSSE(writer, "response.function_call_arguments.delta", map[string]any{ - "type": "response.function_call_arguments.delta", - "item_id": currentTool.id, - "output_index": toolIndexByID[currentTool.id], - "delta": *event.Delta.PartialJSON, - }) - } - case "signature_delta": - // 思考签名无 Responses 对应物,忽略 - } - - case "content_block_stop": - // 块级收尾统一延迟到 message_stop / finish,这里仅结束当前工具块的增量定向 - if currentTool != nil { - currentTool = nil - } - - case "message_delta": - if event.Usage != nil { - if event.Usage.InputTokens > 0 { - usage.InputTokens = event.Usage.InputTokens - } - usage.OutputTokens = event.Usage.OutputTokens - if event.Usage.CacheReadInputTokens > 0 { - usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens - } - if event.Usage.CacheCreationInputTokens > 0 { - usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens - } - if event.Usage.CacheCreation != nil { - usage.CacheCreation = event.Usage.CacheCreation - } - } - - case "message_stop": - finish() - return buildBillingUsage(), nil - - case "error": - errMsg := "claude stream error" - if event.Error != nil { - if b, err := json.Marshal(event.Error); err == nil { - errMsg = fmt.Sprintf("claude stream error: %s", string(b)) - } - } - info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("%s", errMsg)) - g.Log().Warningf(ctx, "[Claude.handleStreamToResponses] %v", errMsg) - if !sentCreated { - errBody, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": errMsg, - "type": "upstream_error", - "param": nil, - "code": "upstream_stream_error", - }, - }) - writeResponsesErrorBody(writer, errBody) - upstreamErr := constant.NewUpstreamError(http.StatusBadGateway, string(errBody), nil) - upstreamErr.ResponseWritten = true - return buildBillingUsage(), upstreamErr - } - return buildBillingUsage(), constant.NewUpstreamError(http.StatusBadGateway, errMsg, nil) - - case "ping": - // 保活事件,忽略 - } - } - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return buildBillingUsage(), fmt.Errorf("stream scanner error: %w", err) - } - - // 上游未发 message_stop 即断流:仍合成 completed,避免客户端挂起 - finish() - - // 计费返回值按 Claude 口径(input 不含缓存,cache 独立列) - return buildBillingUsage(), nil -} - -// writeResponsesErrorBody 写入 Responses 兼容的错误响应体(SSE 头提交后状态码已固定 200) -func writeResponsesErrorBody(writer http.ResponseWriter, body []byte) { - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(body) - if f, ok := writer.(http.Flusher); ok { - f.Flush() + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } + return nil, constant.NewChannelError("claude adaptor: relaykit stream converter unavailable for responses client", nil) } diff --git a/relay/channel/claude/responses_bridge_test.go b/relay/channel/claude/responses_bridge_test.go index 7c2666a..2397632 100644 --- a/relay/channel/claude/responses_bridge_test.go +++ b/relay/channel/claude/responses_bridge_test.go @@ -3,6 +3,7 @@ package claude import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -23,6 +24,7 @@ func responsesInboundInfo(isStream bool) *common.RelayInfo { OriginModelName: "glm-5.3", StreamStatus: common.NewStreamStatus(), ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderClaude), BaseURL: "https://upstream.example.com", UpstreamModelName: "glm-5.3", IsModelMapped: false, @@ -144,7 +146,8 @@ func TestDoResponse_ResponsesInboundStream_TextAndToolCall(t *testing.T) { } // TestDoResponse_ResponsesInboundStream_UpstreamErrorBeforeEvents -// 首个事件即 error:返回 upstream error 并写入错误体,不合成假成功的 response.completed。 +// 首个事件即 error:SSE 头已提交,须向上返回上游错误(502 + ResponseWritten, +// 供调度 FSM 收到上游故障信号且不重写响应体/不换渠道重试),且不合成假成功的 response.completed。 func TestDoResponse_ResponsesInboundStream_UpstreamErrorBeforeEvents(t *testing.T) { ss := strings.Join([]string{ `data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`, @@ -160,11 +163,23 @@ func TestDoResponse_ResponsesInboundStream_UpstreamErrorBeforeEvents(t *testing. rec := httptest.NewRecorder() a := &Adaptor{} _, err := a.DoResponse(context.Background(), resp, info, rec) + // SSE 响应头已提交:状态码不可再改,故错误带 ResponseWritten; + // 但错误本身必须上抛,否则调度 FSM 收不到上游故障信号、且会按成功结算 if err == nil { t.Fatal("upstream error event should return an upstream error, got nil") } - if !strings.Contains(rec.Body.String(), "overloaded_error") { - t.Errorf("error body not written to client: %s", rec.Body.String()) + var relayErr *constant.RelayError + if !errors.As(err, &relayErr) { + t.Fatalf("want *constant.RelayError, got %T: %v", err, err) + } + if relayErr.StatusCode != http.StatusBadGateway { + t.Errorf("StatusCode = %d, want %d", relayErr.StatusCode, http.StatusBadGateway) + } + if !relayErr.ResponseWritten { + t.Error("ResponseWritten = false, want true(SSE 头已提交,上层不得重写响应体)") + } + if got := info.StreamStatus.GetEndReason(); got != common.StreamEndReasonError { + t.Errorf("StreamStatus end reason = %v, want %v", got, common.StreamEndReasonError) } if strings.Contains(rec.Body.String(), "response.completed") { t.Errorf("should not synthesize response.completed on upstream error: %s", rec.Body.String()) diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go index 20797e6..4524582 100644 --- a/relay/channel/cloudflare/adaptor.go +++ b/relay/channel/cloudflare/adaptor.go @@ -55,7 +55,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { prefix := fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1", baseURL, a.accountID) switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions: + // Claude/Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, + constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return prefix + "/chat/completions", nil case constant.RelayModeCompletions: return prefix + "/completions", nil @@ -78,15 +81,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体,执行模型名映射 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if !info.ChannelMeta.IsModelMapped { return bytes.NewReader(requestBody), nil } diff --git a/relay/channel/codex/adaptor.go b/relay/channel/codex/adaptor.go index 7688684..0bbe232 100644 --- a/relay/channel/codex/adaptor.go +++ b/relay/channel/codex/adaptor.go @@ -66,15 +66,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // 做模型名映射,并对齐 Codex CLI 剥离官方不兼容字段(store/max_output_tokens/temperature)、补默认 instructions。 // 以 map 形式操作,保留 Responses 其余字段(input/tools/reasoning 等)原样透传,避免结构体 round-trip 丢字段。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if constant.RelayMode(info.RelayMode) != constant.RelayModeResponses { return nil, fmt.Errorf("codex only supports Responses mode, got relay mode: %d", info.RelayMode) } diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go deleted file mode 100644 index d49b005..0000000 --- a/relay/channel/coze/adaptor.go +++ /dev/null @@ -1,371 +0,0 @@ -package coze - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/qianfree/team-api/relay/channel/openai" - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/constant" - "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" - "github.com/qianfree/team-api/relay/override" - "github.com/qianfree/team-api/relay/relaykit_bridge" -) - -// Adaptor Coze 供应商适配器。 -// Coze v3 API 支持流式 SSE 响应,本适配器将 Coze SSE 事件转换为 OpenAI 格式的 SSE 流。 -// 非流式请求也通过流式端点收集完整响应后以 OpenAI JSON 格式返回。 -type Adaptor struct { - info *common.RelayInfo -} - -func (a *Adaptor) Init(info *common.RelayInfo) { - a.info = info -} - -// GetRequestURL 构建上游请求 URL。Coze v3 chat 端点: {baseURL}/v3/chat -func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { - baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") - return baseURL + "/v3/chat", nil -} - -// SetupRequestHeader 设置上游请求头。ApiKey 为 Coze Personal Access Token。 -func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { - header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) - header.Set("Content-Type", "application/json") - header.Set("Accept", "application/json") - return nil -} - -// ConvertRequest 将 OpenAI Chat 请求转换为 Coze v3 请求格式 -func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - - // 非流式请求也强制使用流式模式,以便在 DoResponse 中统一处理 - cozeBody, err := convertOpenAIToCoze(requestBody, info) - if err != nil { - return nil, fmt.Errorf("convert to Coze request failed: %w", err) - } - - // 强制开启流式模式:Coze 非流式需要轮询,实现复杂, - // 这里统一走流式,非流式场景在 DoResponse 中收集完整响应后一次性返回 - var cozeReq CozeCreateRequest - if err := json.Unmarshal(cozeBody, &cozeReq); err == nil { - cozeReq.Stream = true - cozeBody, _ = json.Marshal(cozeReq) - } - - return bytes.NewReader(cozeBody), nil -} - -// DoRequest 发送请求到 Coze 上游 -func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { - reqURL, err := a.GetRequestURL(info) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) - if err != nil { - return nil, fmt.Errorf("create request failed: %w", err) - } - - if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { - return nil, fmt.Errorf("setup request header failed: %w", err) - } - - if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { - override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) - } - - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 120 // Coze 可能响应较慢,给更长超时 - } - if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { - timeout = 600 - } - - client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) - - return client.Do(httpReq) -} - -// DoResponse 处理 Coze 上游 SSE 响应,转换为 OpenAI 格式。 -// Coze SSE 事件格式: -// -// event: conversation.message.delta -// data: {"role":"assistant","type":"answer","content":"Hello"} -// -// event: conversation.message.completed -// data: {"role":"assistant","type":"answer","content":"Hello world"} -// -// event: done -// data: {} -func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) - } - - if info.IsStream { - return a.handleStreamResponse(ctx, resp, info, writer) - } - return a.handleNonStreamResponse(ctx, resp, info, writer) -} - -// handleStreamResponse 流式模式:逐事件读取 Coze SSE,转换为 OpenAI SSE 格式输出 -func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。 - if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { - // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算 - if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { - return usage, common.ErrStreamInterrupted - } - return usage, nil - } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - streamStatus := info.StreamStatus - if streamStatus == nil { - streamStatus = common.NewStreamStatus() - info.StreamStatus = streamStatus - } - - chatID := fmt.Sprintf("chatcmpl-coze-%s", info.RequestID) - createdAt := time.Now().Unix() - modelName := info.OriginModelName - - scanner := bufio.NewScanner(resp.Body) - var currentEvent string - var completionTokens int - var transferredTextLen int // 已转发的文本长度,供流中断输出估算 - - for scanner.Scan() { - select { - case <-ctx.Done(): - streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) - // 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - interruptedUsage := &common.Usage{} - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen) - return interruptedUsage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - - // 解析 SSE event 行 - if strings.HasPrefix(line, "event:") { - currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - continue - } - - // 解析 SSE data 行 - if !strings.HasPrefix(line, "data:") { - continue - } - data, _ := helper.ExtractSSEData(line) - - info.SetFirstResponseTime() - - switch currentEvent { - case "conversation.message.delta": - var msg CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - continue - } - // 只转发 answer 类型的消息 - if msg.Type != "answer" { - continue - } - completionTokens += helper.EstimateTokens(msg.Content) - transferredTextLen += len(msg.Content) - - chunk := helper.BuildOpenAIStreamChunk(chatID, createdAt, modelName, msg.Content, nil) - chunkJSON, err := json.Marshal(chunk) - if err != nil { - continue - } - if err := helper.WriteSSEData(writer, string(chunkJSON)); err != nil { - streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) - // 流中断计费兜底:输出按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - interruptedUsage := &common.Usage{} - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen) - return interruptedUsage, common.ErrStreamInterrupted - } - - case "conversation.message.completed": - // 完成事件,发送带 finish_reason 的最终 chunk - finishReason := "stop" - chunk := helper.BuildOpenAIStreamChunk(chatID, createdAt, modelName, "", &finishReason) - chunkJSON, _ := json.Marshal(chunk) - _ = helper.WriteSSEData(writer, string(chunkJSON)) - - case "done": - // 流结束 - _ = helper.WriteSSEData(writer, "[DONE]") - streamStatus.SetEndReason(common.StreamEndReasonDone, nil) - return &common.Usage{ - CompletionTokens: completionTokens, - TotalTokens: completionTokens, - }, nil - - case "error": - // Coze 错误事件 - streamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("coze error: %s", data)) - return &common.Usage{CompletionTokens: completionTokens}, fmt.Errorf("coze stream error: %s", data) - } - - currentEvent = "" - } - - if err := scanner.Err(); err != nil { - streamStatus.SetEndReason(common.StreamEndReasonError, err) - return &common.Usage{CompletionTokens: completionTokens}, fmt.Errorf("read coze stream failed: %w", err) - } - - streamStatus.SetEndReason(common.StreamEndReasonDone, nil) - return &common.Usage{ - CompletionTokens: completionTokens, - TotalTokens: completionTokens, - }, nil -} - -// handleNonStreamResponse 非流式模式:读取 Coze SSE 收集完整内容,转换为 OpenAI JSON 响应 -func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read coze response body failed: %w", err) - } - - // relaykit 响应转换路径(Coze 上游为 SSE,桥接把缓冲体交给转换器解析) - if convertedBody, _, ok := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body); ok { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(convertedBody) - if usage, ok := relaykit_bridge.UsageFromConvertedChatResponse(convertedBody); ok { - return usage, nil - } - return &common.Usage{}, nil - } - - // 旧路径:扫描缓冲的 SSE 收集完整内容 - scanner := bufio.NewScanner(strings.NewReader(string(body))) - var currentEvent string - var fullContent strings.Builder - - for scanner.Scan() { - select { - case <-ctx.Done(): - return nil, fmt.Errorf("context cancelled while reading coze response") - default: - } - - line := scanner.Text() - - if strings.HasPrefix(line, "event:") { - currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - data, _ := helper.ExtractSSEData(line) - - info.SetFirstResponseTime() - - switch currentEvent { - case "conversation.message.delta": - var msg CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - continue - } - if msg.Type == "answer" { - fullContent.WriteString(msg.Content) - } - - case "conversation.message.completed": - // completed 事件包含完整内容,优先使用 - var msg CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err == nil && msg.Type == "answer" { - fullContent.Reset() - fullContent.WriteString(msg.Content) - } - - case "error": - return nil, fmt.Errorf("coze error: %s", data) - } - - currentEvent = "" - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read coze response failed: %w", err) - } - - content := fullContent.String() - completionTokens := helper.EstimateTokens(content) - - // 构建 OpenAI 非流式响应 - chatResp := dto.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-coze-%s", info.RequestID), - Object: "chat.completion", - Created: time.Now().Unix(), - Model: info.OriginModelName, - Choices: []dto.Choice{ - { - Index: 0, - Message: dto.Message{ - Role: "assistant", - Content: content, - }, - FinishReason: "stop", - }, - }, - Usage: dto.UsageWithDetails{ - CompletionTokens: completionTokens, - TotalTokens: completionTokens, - }, - } - - respJSON, err := json.Marshal(chatResp) - if err != nil { - return nil, fmt.Errorf("marshal OpenAI response failed: %w", err) - } - - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respJSON) - - return &common.Usage{ - CompletionTokens: completionTokens, - TotalTokens: completionTokens, - }, nil -} - -func (a *Adaptor) GetChannelName() string { - return ChannelName -} - -var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/coze/constants.go b/relay/channel/coze/constants.go deleted file mode 100644 index 0ffe42b..0000000 --- a/relay/channel/coze/constants.go +++ /dev/null @@ -1,4 +0,0 @@ -package coze - -// ChannelName 渠道名称 -var ChannelName = "Coze" diff --git a/relay/channel/coze/converter.go b/relay/channel/coze/converter.go deleted file mode 100644 index 9dfd80e..0000000 --- a/relay/channel/coze/converter.go +++ /dev/null @@ -1,77 +0,0 @@ -package coze - -import ( - "encoding/json" - "fmt" - - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" -) - -// convertOpenAIToCoze 将 OpenAI Chat Completions 请求转换为 Coze v3 请求。 -// BotID 来自上游模型名(UpstreamModelName),即渠道配置中的模型映射目标; -// query 取最后一条 user 角色消息的文本内容。 -func convertOpenAIToCoze(requestBody []byte, info *common.RelayInfo) ([]byte, error) { - var openaiReq dto.GeneralOpenAIRequest - if err := json.Unmarshal(requestBody, &openaiReq); err != nil { - return nil, fmt.Errorf("parse OpenAI request failed: %w", err) - } - - // 提取最后一条 user 消息作为 query - query := extractLastUserMessage(openaiReq.Messages) - if query == "" { - return nil, fmt.Errorf("no user message found in request") - } - - // BotID:优先使用映射后的上游模型名,否则使用原始模型名 - botID := openaiReq.Model - if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { - botID = info.ChannelMeta.UpstreamModelName - } - - // 用户标识 - user := openaiReq.User - if user == "" { - user = fmt.Sprintf("tenant_%d_user_%d", info.TenantID, info.UserID) - } - - // 流式标识 - stream := false - if openaiReq.Stream != nil { - stream = *openaiReq.Stream - } - - cozeReq := CozeCreateRequest{ - BotID: botID, - User: user, - Query: query, - Stream: stream, - } - - return json.Marshal(cozeReq) -} - -// extractLastUserMessage 从消息列表中提取最后一条 user 角色的文本内容 -func extractLastUserMessage(messages []dto.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role != "user" { - continue - } - switch content := messages[i].Content.(type) { - case string: - return content - case []interface{}: - // 多模态消息,提取文本部分 - for _, part := range content { - if m, ok := part.(map[string]interface{}); ok { - if m["type"] == "text" { - if text, ok := m["text"].(string); ok { - return text - } - } - } - } - } - } - return "" -} diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go deleted file mode 100644 index 5972304..0000000 --- a/relay/channel/coze/dto.go +++ /dev/null @@ -1,11 +0,0 @@ -package coze - -import relaykitdto "github.com/qianfree/team-api/relaykit/dto" - -// CozeCreateRequest / CozeMessage 与 relaykit/dto 中定义字节相同的协议结构, -// 别名到 relaykit 统一权威定义,消除本地重复。 -// -// 此前本地还定义了 CozeCreateResponse / CozeRetrieveResponse / -// CozeMessageListResponse,但全仓无任何引用,已删除。 -type CozeCreateRequest = relaykitdto.CozeCreateRequest -type CozeMessage = relaykitdto.CozeMessage diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go index ed828b7..e6cc00e 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -73,28 +73,31 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertClaudeRequestForDeepSeek(requestBody, info) } - // Responses 模式:上游原生支持时保持 Responses 格式(模型映射 + reasoning.effort 注入); - // chat-only 上游(渠道未开 supports_responses)先转 Chat 格式,再叠加 DeepSeek 特有适配 + // Responses 模式:上游原生支持时保持 Responses 格式(模型映射 + reasoning.effort 注入)。 + // chat-only 上游的 Responses→chat 转换已由 relaykit 接管(矩阵 Responses→OpenAI + + // PostProcessConvertedRequest 注入 DeepSeek 适配),此分支不可达,注册缺失时显式报错。 if constant.RelayMode(info.RelayMode) == constant.RelayModeResponses || constant.RelayMode(info.RelayMode) == constant.RelayModeResponsesCompact { if info.ChannelMeta.UpstreamSpeaksResponses() { return convertResponsesRequestForDeepSeek(requestBody, info) } - return convertResponsesToChatForDeepSeek(requestBody, info) + return nil, constant.NewChannelError("deepseek adaptor: relaykit converter unavailable for responses inbound on chat-only channel", nil) } - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted + processed, err := postProcessRequest(requestBody, info) + if err != nil { + return nil, err } + return bytes.NewReader(processed), nil +} +// postProcessRequest DeepSeek 请求适配:模型映射 + stream_options 注入 +// (OpenAI 同格式入站的 adaptor 路径用;relaykit 路径两者均已由桥接层覆盖, +// thinking 后缀方言已随全局虚拟模型后缀机制移除,故不再实现 RequestPostProcessor)。 +func postProcessRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { - return bytes.NewReader(requestBody), nil + return requestBody, nil } // 模型名映射 @@ -105,14 +108,11 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re // 注入 stream_options(流式请求需要 usage 信息用于计费) rawMap = injectStreamOptions(rawMap, info) - // 注入思考模式参数 - rawMap = injectThinkingParams(rawMap, info) - result, err := json.Marshal(rawMap) if err != nil { return nil, fmt.Errorf("marshal converted request failed: %w", err) } - return bytes.NewReader(result), nil + return result, nil } // injectStreamOptions 为流式请求注入 stream_options:{include_usage:true} @@ -126,91 +126,6 @@ func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayIn return rawMap } -// injectThinkingParams 根据 RelayInfo 中的思考后缀注入 DeepSeek 思考模式参数。 -// -// DeepSeek 两类模型的参数差异: -// - V3 思考版(deepseek-chat):通过 thinking.type: "enabled"/"disabled" 控制;启用时需要 budget_tokens -// - R1 系列(deepseek-reasoner):不识别 thinking 参数,通过 reasoning_effort: "none"/"low"/"high"/"max" 控制 -// -// 关闭思考时同时注入两个参数,兼容两类模型: -// - thinking.type: "disabled"(V3 识别) -// - reasoning_effort: "none"(R1 识别) -// -// 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 DeepSeek 默认行为) -func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { - // 客户端已显式设置 thinking 参数 → 不干预 - if _, clientSet := rawMap["thinking"]; clientSet { - return rawMap - } - - // -nothinking 后缀:显式关闭思考 - // V3 模型:thinking.type: "disabled" 生效 - // R1 模型:thinking 参数被忽略,需 reasoning_effort: "none" 才能关闭思考 - if info.ThinkingDisabled { - rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`) - rawMap["reasoning_effort"] = json.RawMessage(`"none"`) - return rawMap - } - - // -thinking 后缀:显式开启思考 + 可选 effort - // budget_tokens 为 V3 思考版必需字段,R1 模型忽略此参数 - if info.ThinkingEnabled { - rawMap["thinking"] = json.RawMessage(`{"type":"enabled","budget_tokens":16000}`) - rawMap = injectReasoningEffort(rawMap, info) - return rawMap - } - - // effort 后缀(-high/-low/-medium 等):开启思考 + 设置 effort - if info.ReasoningEffort != "" { - // 仅在客户端未显式设置 thinking 时注入 - rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) - rawMap = injectReasoningEffort(rawMap, info) - return rawMap - } - - // 无后缀:不干预,保留 DeepSeek 默认行为(默认 enabled) - return rawMap -} - -// injectReasoningEffort 注入 reasoning_effort 参数,映射到 DeepSeek 支持的值 -// -// DeepSeek V3/R1: 支持 high 和 max,映射 low/medium→high, xhigh→max -// DeepSeek V4: 仅支持 max,所有非空 effort 统一映射为 max -func injectReasoningEffort(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { - if info.ReasoningEffort == "" { - return rawMap - } - // 仅在客户端未显式设置时注入 - if _, clientSet := rawMap["reasoning_effort"]; clientSet { - return rawMap - } - - // V4 模型仅支持 max - if isDeepSeekV4Model(info.ChannelMeta.UpstreamModelName) { - rawMap["reasoning_effort"] = json.RawMessage(`"max"`) - return rawMap - } - - // V3/R1: 支持 high 和 max,做兼容映射 - effort := info.ReasoningEffort - switch effort { - case "low", "medium", "minimal": - effort = "high" - case "xhigh", "max": - effort = "max" - default: - effort = "high" - } - rawMap["reasoning_effort"], _ = json.Marshal(effort) - return rawMap -} - -// isDeepSeekV4Model 判断是否为 DeepSeek V4 系列模型 -func isDeepSeekV4Model(modelName string) bool { - return strings.HasPrefix(modelName, "deepseek-v4") || - strings.HasPrefix(modelName, "deepseek_v4") -} - func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { reqURL, err := a.GetRequestURL(info) if err != nil { @@ -274,8 +189,6 @@ func convertClaudeRequestForDeepSeek(requestBody []byte, info *common.RelayInfo) rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) } - rawMap = injectThinkingParams(rawMap, info) - result, err := json.Marshal(rawMap) if err != nil { return bytes.NewReader(requestBody), nil @@ -297,91 +210,9 @@ func convertResponsesRequestForDeepSeek(requestBody []byte, info *common.RelayIn rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) } - // 将思考后缀参数映射到 reasoning.effort(Responses API 格式) - rawMap = injectResponsesReasoningEffort(rawMap, info) - result, err := json.Marshal(rawMap) if err != nil { return bytes.NewReader(requestBody), nil } return bytes.NewReader(result), nil } - -// convertResponsesToChatForDeepSeek chat-only 上游兜底:将 Responses 请求转换为 Chat Completions 格式, -// 再叠加 DeepSeek 特有适配(stream_options / 思考参数注入)。 -// 模型映射与 ResponsesRequest 快照(响应侧合成 Responses 格式时 echo 请求参数)由 -// ConvertResponsesToOpenAI 完成;思考后缀经 chat 侧 reasoning_effort 映射注入。 -func convertResponsesToChatForDeepSeek(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - converted, err := openai.ConvertResponsesToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - chatBody, err := io.ReadAll(converted) - if err != nil { - return nil, fmt.Errorf("read converted chat body failed: %w", err) - } - - var rawMap map[string]json.RawMessage - if err := json.Unmarshal(chatBody, &rawMap); err != nil { - return bytes.NewReader(chatBody), nil - } - - rawMap = injectStreamOptions(rawMap, info) - rawMap = injectThinkingParams(rawMap, info) - - result, err := json.Marshal(rawMap) - if err != nil { - return nil, fmt.Errorf("marshal converted request failed: %w", err) - } - return bytes.NewReader(result), nil -} - -// injectResponsesReasoningEffort 为 Responses API 请求注入 DeepSeek reasoning.effort 参数。 -// -// Responses API 用 reasoning.effort 而非 Chat API 的 reasoning_effort 顶层字段: -// - DeepSeek V4:仅支持 "max",所有非空 effort 统一映射为 "max" -// - DeepSeek V3/R1:支持 "none"/"high"/"max",做兼容映射 -// -// 注入优先级:客户端已显式设置 reasoning > 后缀路由注入 > 不干预(保留 DeepSeek 默认行为) -func injectResponsesReasoningEffort(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { - // 客户端已显式设置 reasoning 字段 → 不干预 - if _, clientSet := rawMap["reasoning"]; clientSet { - return rawMap - } - - // -nothinking 后缀:显式关闭思考 - if info.ThinkingDisabled { - rawMap["reasoning"] = json.RawMessage(`{"effort":"none"}`) - return rawMap - } - - // 确定目标 effort(来自 -thinking 或 -high/-low/-medium 后缀) - effort := info.ReasoningEffort - if info.ThinkingEnabled && effort == "" { - effort = "max" - } - if effort == "" { - return rawMap // 无后缀,不干预,保留 DeepSeek 默认行为 - } - - modelName := info.ChannelMeta.UpstreamModelName - - // V4 模型仅支持 max - if isDeepSeekV4Model(modelName) { - rawMap["reasoning"] = json.RawMessage(`{"effort":"max"}`) - return rawMap - } - - // V3/R1:映射到支持的值(low/medium → high,xhigh/max → max) - switch effort { - case "low", "medium", "minimal": - effort = "high" - case "xhigh", "max": - effort = "max" - default: - effort = "high" - } - effortJSON, _ := json.Marshal(effort) - rawMap["reasoning"] = json.RawMessage(`{"effort":` + string(effortJSON) + `}`) - return rawMap -} diff --git a/relay/channel/deepseek/adaptor_test.go b/relay/channel/deepseek/adaptor_test.go index 6b3a24f..e72222a 100644 --- a/relay/channel/deepseek/adaptor_test.go +++ b/relay/channel/deepseek/adaptor_test.go @@ -3,8 +3,8 @@ package deepseek import ( "context" "encoding/json" + "errors" "io" - "strings" "testing" "github.com/qianfree/team-api/relay/common" @@ -61,60 +61,41 @@ func TestGetRequestURL_ResponsesFlagRouting(t *testing.T) { const deepseekResponsesRequestBody = `{"model":"deepseek-v4-flash","instructions":"You are helpful.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}],"stream":true}` -// TestConvertRequest_ResponsesChatFallback 渠道未开 supports_responses 时, -// Responses 请求必须转换为 Chat 格式(instructions→system、input→messages、stream 保留), -// 不能再以 Responses 体直发 chat-only 上游。 -func TestConvertRequest_ResponsesChatFallback(t *testing.T) { +// TestConvertRequest_ResponsesChatFallback_HardFail chat-only 上游的 Responses→chat +// 转换已由 relaykit 接管(handler 侧矩阵 Responses→OpenAI + PostProcessConvertedRequest +// 注入 stream_options/thinking),adaptor.ConvertRequest 不再承载该路径。 +// 此处验证守卫语义:该组合到达 adaptor 时(仅矩阵注册缺失的程序性异常)必须显式报错, +// 不得把 Responses 体静默透传给 chat-only 端点。 +func TestConvertRequest_ResponsesChatFallback_HardFail(t *testing.T) { a := &Adaptor{} info := deepseekResponsesInfo(false) - out, err := a.ConvertRequest(context.Background(), info, []byte(deepseekResponsesRequestBody)) + _, err := a.ConvertRequest(context.Background(), info, []byte(deepseekResponsesRequestBody)) + if err == nil { + t.Fatal("chat-only 上游的 Responses 入站到达 adaptor 应显式报错,而非静默透传") + } + var relayErr *constant.RelayError + if !errors.As(err, &relayErr) { + t.Fatalf("want *constant.RelayError, got %T: %v", err, err) + } +} + +// TestPostProcessRequest_StreamOptions OpenAI 同格式入站的 adaptor 路径: +// 流式请求须注入 stream_options(计费需要 usage)。 +func TestPostProcessRequest_StreamOptions(t *testing.T) { + info := deepseekResponsesInfo(false) + info.IsStream = true + chatBody := []byte(`{"model":"deepseek-v4-flash","stream":true,"messages":[{"role":"user","content":"你好"}]}`) + + out, err := postProcessRequest(chatBody, info) if err != nil { - t.Fatalf("ConvertRequest error: %v", err) + t.Fatalf("postProcessRequest error: %v", err) } - raw, _ := io.ReadAll(out) var m map[string]json.RawMessage - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("converted body is not json: %v, body: %s", err, raw) - } - - // Responses 专属字段必须消失,chat 专属字段必须存在 - if _, ok := m["input"]; ok { - t.Error("input field should be converted to messages") - } - msgsRaw, ok := m["messages"] - if !ok { - t.Fatalf("messages field missing: %s", raw) - } - - // instructions → system 消息,input → user 消息 - var msgs []struct { - Role string `json:"role"` - Content json.RawMessage `json:"content"` - } - if err := json.Unmarshal(msgsRaw, &msgs); err != nil { - t.Fatalf("unmarshal messages: %v", err) - } - if len(msgs) != 2 { - t.Fatalf("messages len = %d, want 2 (system + user): %s", len(msgs), raw) - } - if msgs[0].Role != "system" || strings.TrimSpace(string(msgs[0].Content)) != `"You are helpful."` { - t.Errorf("first message should be system with instructions, got role=%s content=%s", msgs[0].Role, msgs[0].Content) - } - if msgs[1].Role != "user" || !strings.Contains(string(msgs[1].Content), "你好") { - t.Errorf("second message should be user with input text, got role=%s content=%s", msgs[1].Role, msgs[1].Content) - } - - // stream 保留 + 注入 stream_options(usage 计费需要) - if string(m["stream"]) != "true" { - t.Errorf("stream should be preserved as true, got %s", m["stream"]) + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("output is not json: %v, body: %s", err, out) } if _, ok := m["stream_options"]; !ok { - t.Errorf("stream_options should be injected for stream request: %s", raw) - } - - // ResponsesRequest 快照需落 RelayInfo,供响应侧合成 Responses 格式时 echo - if info.ResponsesRequest == nil { - t.Error("info.ResponsesRequest snapshot should be stashed by conversion") + t.Errorf("stream_options should be injected for stream request: %s", out) } } diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go deleted file mode 100644 index c5501f4..0000000 --- a/relay/channel/dify/adaptor.go +++ /dev/null @@ -1,322 +0,0 @@ -package dify - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/qianfree/team-api/relay/channel/openai" - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/constant" - "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" - "github.com/qianfree/team-api/relay/override" - "github.com/qianfree/team-api/relay/relaykit_bridge" -) - -// Adaptor Dify 供应商适配器。 -// Dify 是开源 LLM 应用开发平台,通过 App API 提供对话能力。 -// 本适配器将 OpenAI Chat Completions 请求转换为 Dify chat-messages 格式, -// 并将 Dify 响应(blocking/streaming)转换回 OpenAI 格式。 -type Adaptor struct { - info *common.RelayInfo -} - -func (a *Adaptor) Init(info *common.RelayInfo) { - a.info = info -} - -// GetRequestURL 构建上游请求 URL。Dify chat-messages 端点: {baseURL}/v1/chat-messages -func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { - baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") - - switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions: - return baseURL + "/v1/chat-messages", nil - default: - return "", fmt.Errorf("unsupported relay mode for Dify: %d", info.RelayMode) - } -} - -// SetupRequestHeader 设置上游请求头。ApiKey 为 Dify App API Key。 -func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) error { - header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) - header.Set("Content-Type", "application/json") - header.Set("Accept", "application/json") - return nil -} - -// ConvertRequest 将 OpenAI Chat 请求转换为 Dify 请求格式 -func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - - difyBody, err := convertOpenAIToDify(requestBody, info) - if err != nil { - return nil, fmt.Errorf("convert to Dify request failed: %w", err) - } - return bytes.NewReader(difyBody), nil -} - -// DoRequest 发送请求到 Dify 上游 -func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { - reqURL, err := a.GetRequestURL(info) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) - if err != nil { - return nil, fmt.Errorf("create request failed: %w", err) - } - - if err := a.SetupRequestHeader(httpReq.Header, info); err != nil { - return nil, fmt.Errorf("setup request header failed: %w", err) - } - - if hdrOverrides, hdrErr := override.ApplyHeaderOverride(info); hdrErr == nil && len(hdrOverrides) > 0 { - override.MergeHeaderOverrides(httpReq.Header, hdrOverrides) - } - - timeout := info.ChannelMeta.Settings.TimeoutSeconds - if timeout <= 0 { - timeout = 120 // Dify 应用可能包含复杂工作流,给更长超时 - } - if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 { - timeout = 600 - } - - client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream) - - return client.Do(httpReq) -} - -// DoResponse 处理 Dify 上游响应,转换为 OpenAI 格式。 -// -// Dify blocking 模式返回 JSON: -// -// {"answer": "...", "metadata": {"usage": {"total_tokens": N}}} -// -// Dify streaming 模式返回 SSE: -// -// data: {"event": "message", "answer": "chunk text"} -// data: {"event": "message_end", "metadata": {"usage": {...}}} -func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) - } - - if info.IsStream { - return a.handleStreamResponse(ctx, resp, info, writer) - } - return a.handleNonStreamResponse(ctx, resp, info, writer) -} - -// handleNonStreamResponse 处理 Dify blocking 模式响应 -func (a *Adaptor) handleNonStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read Dify response body failed: %w", err) - } - - info.SetFirstResponseTime() - - // relaykit 响应转换路径(特性开关控制,默认关闭)。未启用/失败回退旧路径。 - if convertedBody, _, ok := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body); ok { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(convertedBody) - if usage, ok := relaykit_bridge.UsageFromConvertedChatResponse(convertedBody); ok { - return usage, nil - } - return &common.Usage{}, nil - } - - var difyResp DifyBlockingResponse - if err := json.Unmarshal(body, &difyResp); err != nil { - return nil, fmt.Errorf("parse Dify response failed: %w", err) - } - - // 构建 OpenAI 非流式响应 - chatResp := dto.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-dify-%s", info.RequestID), - Object: "chat.completion", - Created: time.Now().Unix(), - Model: info.OriginModelName, - Choices: []dto.Choice{ - { - Index: 0, - Message: dto.Message{ - Role: "assistant", - Content: difyResp.Answer, - }, - FinishReason: "stop", - }, - }, - Usage: dto.UsageWithDetails{ - PromptTokens: difyResp.Metadata.Usage.PromptTokens, - CompletionTokens: difyResp.Metadata.Usage.CompletionTokens, - TotalTokens: difyResp.Metadata.Usage.TotalTokens, - }, - } - - // 如果 Dify 未返回用量信息,使用粗略估算 - if chatResp.Usage.TotalTokens == 0 { - chatResp.Usage.CompletionTokens = helper.EstimateTokens(difyResp.Answer) - chatResp.Usage.TotalTokens = chatResp.Usage.CompletionTokens - } - - respJSON, err := json.Marshal(chatResp) - if err != nil { - return nil, fmt.Errorf("marshal OpenAI response failed: %w", err) - } - - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respJSON) - - return &common.Usage{ - PromptTokens: chatResp.Usage.PromptTokens, - CompletionTokens: chatResp.Usage.CompletionTokens, - TotalTokens: chatResp.Usage.TotalTokens, - }, nil -} - -// handleStreamResponse 处理 Dify streaming 模式 SSE 响应 -func (a *Adaptor) handleStreamResponse(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。 - if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { - // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算 - if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { - return usage, common.ErrStreamInterrupted - } - return usage, nil - } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - streamStatus := info.StreamStatus - if streamStatus == nil { - streamStatus = common.NewStreamStatus() - info.StreamStatus = streamStatus - } - - chatID := fmt.Sprintf("chatcmpl-dify-%s", info.RequestID) - createdAt := time.Now().Unix() - modelName := info.OriginModelName - - scanner := bufio.NewScanner(resp.Body) - var usage common.Usage - var transferredTextLen int // 已转发的文本长度,供流中断输出估算 - - for scanner.Scan() { - select { - case <-ctx.Done(): - streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen) - return &usage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - - // Dify SSE 格式: "data: {...}" - if !strings.HasPrefix(line, "data:") { - continue - } - data, _ := helper.ExtractSSEData(line) - if data == "" { - continue - } - - info.SetFirstResponseTime() - - var event DifyStreamEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - continue - } - - switch event.Event { - case "message": - // 内容增量 - if event.Answer == "" { - continue - } - transferredTextLen += len(event.Answer) - - chunk := helper.BuildOpenAIStreamChunk(chatID, createdAt, modelName, event.Answer, nil) - chunkJSON, err := json.Marshal(chunk) - if err != nil { - continue - } - if err := helper.WriteSSEData(writer, string(chunkJSON)); err != nil { - streamStatus.SetEndReason(common.StreamEndReasonClientGone, common.ErrStreamInterrupted) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen) - return &usage, common.ErrStreamInterrupted - } - - case "message_end": - // 流结束,包含用量信息 - usage.PromptTokens = event.Metadata.Usage.PromptTokens - usage.CompletionTokens = event.Metadata.Usage.CompletionTokens - usage.TotalTokens = event.Metadata.Usage.TotalTokens - - // 发送带 finish_reason 的最终 chunk - finishReason := "stop" - chunk := helper.BuildOpenAIStreamChunk(chatID, createdAt, modelName, "", &finishReason) - chunkJSON, _ := json.Marshal(chunk) - _ = helper.WriteSSEData(writer, string(chunkJSON)) - - // 发送 [DONE] - _ = helper.WriteSSEData(writer, "[DONE]") - streamStatus.SetEndReason(common.StreamEndReasonDone, nil) - return &usage, nil - - case "error": - streamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("dify error: %s", data)) - return &usage, fmt.Errorf("dify stream error: %s", data) - } - } - - if err := scanner.Err(); err != nil { - streamStatus.SetEndReason(common.StreamEndReasonError, err) - return &usage, fmt.Errorf("read dify stream failed: %w", err) - } - - // 流异常结束(未收到 message_end),补充兜底的结束 chunk 和 [DONE] - if streamStatus.GetEndReason() == "" { - finishReason := "stop" - chunk := helper.BuildOpenAIStreamChunk(chatID, createdAt, modelName, "", &finishReason) - chunkJSON, _ := json.Marshal(chunk) - _ = helper.WriteSSEData(writer, string(chunkJSON)) - _ = helper.WriteSSEData(writer, "[DONE]") - streamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - - return &usage, nil -} - -func (a *Adaptor) GetChannelName() string { - return ChannelName -} - -var _ common.Adaptor = (*Adaptor)(nil) diff --git a/relay/channel/dify/constants.go b/relay/channel/dify/constants.go deleted file mode 100644 index 4a00490..0000000 --- a/relay/channel/dify/constants.go +++ /dev/null @@ -1,4 +0,0 @@ -package dify - -// ChannelName 渠道名称 -var ChannelName = "Dify" diff --git a/relay/channel/dify/converter.go b/relay/channel/dify/converter.go deleted file mode 100644 index 9b2a1d9..0000000 --- a/relay/channel/dify/converter.go +++ /dev/null @@ -1,100 +0,0 @@ -package dify - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" - relaykitdto "github.com/qianfree/team-api/relaykit/dto" -) - -// DifyRequest / DifyBlockingResponse / DifyStreamEvent 与 relaykit/dto 中定义字节相同 -// (仅本地曾用匿名嵌套 struct 表达 Metadata.Usage,relaykit 用具名 DifyMeta/DifyUsage; -// JSON 序列化等价,字段访问路径一致)。别名到 relaykit 统一权威定义,消除本地重复。 -type DifyRequest = relaykitdto.DifyRequest -type DifyBlockingResponse = relaykitdto.DifyBlockingResponse -type DifyStreamEvent = relaykitdto.DifyStreamEvent - -// convertOpenAIToDify 将 OpenAI Chat Completions 请求转换为 Dify 请求格式。 -// 所有消息被拼接为一个 query 字符串,最后一条 user 消息作为主要查询内容。 -func convertOpenAIToDify(requestBody []byte, info *common.RelayInfo) ([]byte, error) { - var openaiReq dto.GeneralOpenAIRequest - if err := json.Unmarshal(requestBody, &openaiReq); err != nil { - return nil, fmt.Errorf("parse OpenAI request failed: %w", err) - } - - // 拼接所有消息为 query 字符串 - query := flattenMessages(openaiReq.Messages) - if query == "" { - return nil, fmt.Errorf("no messages found in request") - } - - // 确定 response_mode - responseMode := "blocking" - if info.IsStream { - responseMode = "streaming" - } - - // 用户标识 - user := openaiReq.User - if user == "" { - user = "relay-user" - } - - difyReq := DifyRequest{ - Inputs: map[string]interface{}{}, - Query: query, - ResponseMode: responseMode, - User: user, - } - - return json.Marshal(difyReq) -} - -// flattenMessages 将 OpenAI 消息列表拼接为带角色前缀的字符串。 -// 格式: "System: ...\nUser: ...\nAssistant: ..." -// 最后一条 user 消息作为主要查询。 -func flattenMessages(messages []dto.Message) string { - if len(messages) == 0 { - return "" - } - - var parts []string - for _, msg := range messages { - text := extractTextContent(msg.Content) - if text == "" { - continue - } - - prefix := strings.Title(msg.Role) - parts = append(parts, prefix+": "+text) - } - - return strings.Join(parts, "\n") -} - -// extractTextContent 从消息内容中提取文本。 -// Content 可以是 string 或 []ContentPart(多模态)。 -func extractTextContent(content interface{}) string { - switch c := content.(type) { - case string: - return c - case []interface{}: - // 多模态消息,提取文本部分 - var texts []string - for _, part := range c { - if m, ok := part.(map[string]interface{}); ok { - if m["type"] == "text" { - if text, ok := m["text"].(string); ok { - texts = append(texts, text) - } - } - } - } - return strings.Join(texts, " ") - default: - return "" - } -} diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go index 9b99f33..cf83291 100644 --- a/relay/channel/gemini/adaptor.go +++ b/relay/channel/gemini/adaptor.go @@ -62,13 +62,18 @@ func (a *Adaptor) isCodeAssistForcedStream() bool { return false } mode := constant.RelayMode(a.info.RelayMode) - return mode == constant.RelayModeChatCompletions || mode == constant.RelayModeGeminiChat + return mode == constant.RelayModeChatCompletions || mode == constant.RelayModeGeminiChat || + mode == constant.RelayModeClaudeMessages || + mode == constant.RelayModeResponses || mode == constant.RelayModeResponsesCompact } // getRelayAction 获取当前 relay 模式对应的 Gemini action 名称 func (a *Adaptor) getRelayAction(info *common.RelayInfo) (string, error) { switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat: + // Claude/Responses 入站:矩阵已把请求体转成 Gemini 原生格式,与 chat 入站同走 generateContent + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat, + constant.RelayModeClaudeMessages, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: if info.IsStream { return "streamGenerateContent", nil } @@ -111,7 +116,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { model := info.ChannelMeta.UpstreamModelName switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat: + // Claude/Responses 入站:矩阵已把请求体转成 Gemini 原生格式,与 chat 入站同走 generateContent + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat, + constant.RelayModeClaudeMessages, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: if info.IsStream { return fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", baseURL, model), nil } @@ -169,82 +177,15 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertImageRequestToChat(requestBody, info) } - var converted io.Reader - switch info.InboundFormat { - case constant.RelayFormatGemini: - // Gemini 原生格式通过 URL 路径控制流式,body 中的 "stream" 字段会导致上游报错 - cleaned := helper.StripStreamField(requestBody) - converted = bytes.NewReader(cleaned) - case constant.RelayFormatOpenAI: - r, err := ConvertOpenAIToGemini(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatClaude: - r, err := ConvertClaudeToGemini(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatResponses: - r, err := ConvertResponsesToGemini(requestBody, info) - if err != nil { - return nil, err - } - converted = r - default: - r, err := ConvertOpenAIToGemini(requestBody, info) - if err != nil { - return nil, err - } - converted = r + // OpenAI/Claude/Responses 入站 → Gemini 上游已由 relaykit 转换矩阵接管 + //(convertRequestBody 在 adaptor 之前完成转换),此处只剩 Gemini 同格式路径 + //(直连判定未通过时)。矩阵意外未接管时显式报错,避免外格式体静默透传。 + if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatGemini { + return nil, constant.NewChannelError(fmt.Sprintf("gemini adaptor: relaykit converter unavailable for %s inbound", info.InboundFormat), nil) } - - // Thinking 后缀路由 - if info.ThinkingEnabled || info.ReasoningEffort != "" { - converted = injectGeminiThinking(converted, info) - } - - return converted, nil -} - -// injectGeminiThinking 注入 Gemini thinking 配置 -func injectGeminiThinking(r io.Reader, info *common.RelayInfo) io.Reader { - body, err := io.ReadAll(r) - if err != nil { - return r - } - var req map[string]json.RawMessage - if err := json.Unmarshal(body, &req); err != nil { - return bytes.NewReader(body) - } - - if info.ThinkingEnabled { - // -thinking: 设置 thoughtBudget - var maxTokens int - if mt, ok := req["maxOutputTokens"]; ok { - _ = json.Unmarshal(mt, &maxTokens) - } - if maxTokens < 128 { - maxTokens = 8192 - } - budget := maxTokens * 80 / 100 - if budget < 128 { - budget = 128 - } - req["thinkingConfig"] = json.RawMessage(fmt.Sprintf(`{"thoughtBudget":%d,"includeThoughts":true}`, budget)) - } else if info.ReasoningEffort != "" { - // effort 后缀:设置 thinkingLevel - req["thinkingConfig"] = json.RawMessage(fmt.Sprintf(`{"thinkingLevel":"%s","includeThoughts":true}`, - strings.ToUpper(info.ReasoningEffort))) - } - - result, err := json.Marshal(req) - if err != nil { - return bytes.NewReader(body) - } - return bytes.NewReader(result) + // Gemini 原生格式通过 URL 路径控制流式,body 中的 "stream" 字段会导致上游报错 + cleaned := helper.StripStreamField(requestBody) + return bytes.NewReader(cleaned), nil } // wrapCodeAssistBody 将 Gemini 请求体包装为 Code Assist 格式 @@ -329,16 +270,32 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com switch clientFormat { case constant.RelayFormatGemini: return a.handleGeminiNativeResponse(ctx, resp, info, writer) + case constant.RelayFormatClaude: + // Claude 入站(请求侧走 relaykit Claude→Gemini 转换):响应必须转回 Claude Messages 格式, + // 否则 Anthropic SDK 拿到 OpenAI chunk 解析失败 + if info.IsStream { + return a.handleStreamToClaude(ctx, resp, info, writer) + } + return a.handleNonStreamToClaude(ctx, resp, info, writer) case constant.RelayFormatOpenAI: if info.IsStream { return a.handleStreamToOpenAI(ctx, resp, info, writer) } return a.handleNonStreamToOpenAI(ctx, resp, info, writer) - default: + case constant.RelayFormatResponses: + // Responses 入站(请求侧走 relaykit Responses→Gemini 转换):响应必须转回 Responses 格式, + // 否则客户端拿到 chat chunk 解析失败 if info.IsStream { - return a.handleStreamToOpenAI(ctx, resp, info, writer) + return a.handleStreamToResponses(ctx, resp, info, writer) } - return a.handleNonStreamToOpenAI(ctx, resp, info, writer) + return a.handleNonStreamToResponses(ctx, resp, info, writer) + default: + // openai / claude / gemini / responses 四种入站格式均已显式处理 + //(见 relay_handler.go 的 relayModeToInboundFormat)。走到这里说明新增了客户端格式 + // 却漏接响应侧转换——显式失败,而不是静默按 OpenAI 格式写出让客户端 SDK 解析失败 + //(claude / responses 两个缺口此前正是这样被掩盖的)。 + return nil, constant.NewChannelError( + fmt.Sprintf("gemini adaptor: unsupported client format %q", clientFormat), nil) } } @@ -492,6 +449,20 @@ func (a *Adaptor) handleCodeAssistAggregatedStream(ctx context.Context, resp *ht writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) _, _ = writer.Write(respBody) + case constant.RelayFormatClaude: + claudeResp := geminiToClaudeResponse(&aggregated, info) + respBody, _ := json.Marshal(claudeResp) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) + case constant.RelayFormatResponses: + respBody, _, err := buildResponsesBodyFromGemini(&aggregated, info) + if err != nil { + return nil, err + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(respBody) default: openaiResp := geminiToOpenAIResponse(&aggregated, info) respBody, _ := json.Marshal(openaiResp) diff --git a/relay/channel/gemini/claude_bridge.go b/relay/channel/gemini/claude_bridge.go new file mode 100644 index 0000000..1414a2c --- /dev/null +++ b/relay/channel/gemini/claude_bridge.go @@ -0,0 +1,243 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/relaykit_bridge" +) + +// ========== Claude 入站桥接:Gemini → Claude Messages ========== +// +// 请求侧由 relaykit 的步骤链完成(Claude → OpenAI 中枢 → Gemini), +// 响应侧同样由 relaykit 转换器完成(Gemini → Claude,含 SSE 事件序列产出)。 +// 本文件只保留宿主侧接线:HTTP 状态码/安全过滤错误处理、响应写出与计费用量提取, +// 以及 Code Assist 强制流式聚合路径复用的非流式装配函数(geminiToClaudeResponse 及其助手, +// 由 adaptor.go 的 handleCodeAssistAggregatedStream 调用)。 +// +// 协议落差(functionCall 无调用 ID、inlineData 等无对应块类型、thoughtSignature 往返) +// 的处理口径已随转换逻辑迁入 relaykit(relaykit/relayconvert/internal/claude_gemini), +// 此处保留的聚合装配函数沿用同一套约定。 +// +// 计费口径:写给客户端的是 Claude 协议口径的 usage(由转换器产出), +// 返回给上层计费的是 Gemini 原生口径(candidates+thoughts 合计、prompt 含 cached), +// 因此非流式路径仍从**原始上游响应体**提取 usage。 + +// handleNonStreamToClaude 将 Gemini 非流式响应转换为 Claude Messages 格式 +func (a *Adaptor) handleNonStreamToClaude(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + + if resp.StatusCode != http.StatusOK { + // 不写响应:Claude 入站由上层 WriteClaudeRelayError 统一写入,避免双重写入与重试时的响应污染 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) + } + + // 安全过滤(promptFeedback.blockReason)属上游错误语义,先于协议转换判定 + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, constant.NewRequestError( + fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, + ) + } + + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("gemini adaptor: no relaykit converter for claude client response", nil) + } + if convErr != nil { + return nil, relaykit_bridge.ResponseConvertError(convErr, resp.StatusCode, resp.Header) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(convertedBody) + + // 计费用量仍走 Gemini 口径(candidates+thoughts 合计、prompt 含 cached), + // 与本适配器其他出站路径一致;上面写给客户端的是 Claude 协议口径。 + if geminiResp.UsageMetadata != nil { + return geminiUsageToCommon(geminiResp.UsageMetadata), nil + } + return &common.Usage{}, nil +} + +// handleStreamToClaude 将 Gemini 流式响应转换为 Claude Messages SSE 格式 +func (a *Adaptor) handleStreamToClaude(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // 不写响应:交上层 WriteClaudeRelayError 统一写入,避免双重写入与重试时的响应污染 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) + } + + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err + } + return nil, constant.NewChannelError("gemini adaptor: relaykit stream converter unavailable for claude client", nil) +} + +// geminiToClaudeResponse 将 Gemini 非流式响应转换为 Claude Messages 响应。 +// 内容块顺序保持 Gemini parts 的原始顺序(模型已按语义排好,重排会改变思考与正文的先后)。 +func geminiToClaudeResponse(geminiResp *dto.GeminiChatResponse, info *common.RelayInfo) dto.ClaudeResponse { + content := make([]dto.ClaudeContentBlock, 0) + var ( + finishReason string + hasToolUse bool + toolIdx int + ) + + if len(geminiResp.Candidates) > 0 { + candidate := geminiResp.Candidates[0] + finishReason = candidate.FinishReason + + if candidate.Content != nil { + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" { + if isThought { + content = append(content, dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(part.Text), + Signature: part.ThoughtSignature, + }) + } else { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(part.Text), + }) + } + } + + if part.FunctionCall != nil { + content = append(content, dto.ClaudeContentBlock{ + Type: "tool_use", + ID: claudeToolUseID(info, toolIdx), + Name: part.FunctionCall.FunctionName, + Input: geminiFunctionArgs(part.FunctionCall), + }) + toolIdx++ + hasToolUse = true + } + + if fallback := geminiPartFallbackText(part); fallback != "" { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(fallback), + }) + } + } + } + } + + // Claude 协议要求 content 非空 + if len(content) == 0 { + content = append(content, dto.ClaudeContentBlock{Type: "text", Text: strPtr("")}) + } + + stopReason := common.GeminiFinishReasonToClaude(finishReason) + if hasToolUse { + // Gemini 带 functionCall 时 finishReason 仍是 STOP,Claude 协议要求 tool_use + stopReason = common.ClaudeToolUse + } + + modelName := geminiResp.ModelName + if modelName == "" || info.ChannelMeta.IsModelMapped { + modelName = info.OriginModelName + } + + return dto.ClaudeResponse{ + ID: fmt.Sprintf("msg_%s", info.RequestID), + Type: "message", + Role: "assistant", + Content: content, + StopReason: stopReason, + StopSequence: nil, + Model: modelName, + Usage: claudeUsageFromGemini(geminiResp.UsageMetadata), + } +} + +// claudeUsageFromGemini 按 Claude 协议口径换算用量(客户端可见值,非计费值)。 +// - Claude 的 input_tokens 不含缓存命中,Gemini 的 promptTokenCount 已含 cachedContentTokenCount,需扣减 +// - Claude 的 output_tokens 含思考,Gemini 的 candidatesTokenCount 不含 thoughtsTokenCount,需合计 +func claudeUsageFromGemini(um *dto.GeminiUsageMetadata) *dto.ClaudeUsage { + if um == nil { + return &dto.ClaudeUsage{} + } + return &dto.ClaudeUsage{ + InputTokens: claudeInputTokens(um), + OutputTokens: um.CandidatesTokenCount + um.ThoughtsTokenCount, + CacheReadInputTokens: um.CachedContentTokenCount, + } +} + +// claudeInputTokens 计算 Claude 口径的 input_tokens(扣除缓存命中部分) +func claudeInputTokens(um *dto.GeminiUsageMetadata) int { + if um == nil { + return 0 + } + input := um.PromptTokenCount - um.CachedContentTokenCount + if input < 0 { + return 0 + } + return input +} + +// geminiFunctionArgs 提取 functionCall 参数,nil 时回退为空对象 +// (Claude 的 tool_use.input 不允许为 null) +func geminiFunctionArgs(fc *dto.GeminiFunctionCall) any { + if fc == nil || fc.Arguments == nil { + return map[string]any{} + } + return fc.Arguments +} + +// claudeToolUseID 为 Gemini functionCall 合成 Claude tool_use ID。 +// Gemini 协议不带调用 ID,客户端回传的 tool_result.tool_use_id 在请求侧 +// (relaykit 步骤链 Claude → OpenAI → Gemini)按函数名还原,故此处只需 +// 在单次响应内唯一且可被客户端原样回传。 +func claudeToolUseID(info *common.RelayInfo, idx int) string { + return fmt.Sprintf("toolu_%s_%d", info.RequestID, idx) +} + +// geminiPartFallbackText 将 Claude 无对应内容块类型的 Gemini part 渲染为文本。 +// 渲染口径与 Gemini→OpenAI 出站一致(response.go 的 handleStreamToOpenAI)。 +func geminiPartFallbackText(part *dto.GeminiPart) string { + switch { + case part.InlineData != nil: + return fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data) + case part.ExecutableCode != nil: + return fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) + case part.CodeExecutionResult != nil: + return fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) + case part.FileData != nil: + return fmt.Sprintf("[file](%s)", part.FileData.FileURI) + } + return "" +} + +// intPtr 返回 int 的指针 +func intPtr(v int) *int { return &v } + +// strPtr 返回 string 的指针 +func strPtr(v string) *string { return &v } diff --git a/relay/channel/gemini/claude_bridge_test.go b/relay/channel/gemini/claude_bridge_test.go new file mode 100644 index 0000000..500f29e --- /dev/null +++ b/relay/channel/gemini/claude_bridge_test.go @@ -0,0 +1,568 @@ +package gemini + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// assertContentBlockedError 断言错误为「内容安全拦截」口径:请求类错误(4xx)而非 +// 上游故障(5xx),且带 ResponseWritten。 +// +// 分类很关键:安全拦截由客户端提示词触发,若按 5xx 上游错误上报,正常渠道会因用户 +// 发送违规内容被扣健康分乃至熔断;ResponseWritten 则保证上层不重写响应体、不换渠道重试 +// (SSE 头与 200 状态码已提交,且已补齐收尾事件)。 +func assertContentBlockedError(t *testing.T, err error) { + t.Helper() + var relayErr *constant.RelayError + if !errors.As(err, &relayErr) { + t.Fatalf("want *constant.RelayError, got %T: %v", err, err) + } + if relayErr.StatusCode >= 500 { + t.Errorf("StatusCode = %d, want 4xx(安全拦截属客户端内容问题,不得罚渠道健康)", relayErr.StatusCode) + } + if !relayErr.ResponseWritten { + t.Error("ResponseWritten = false, want true(SSE 头已提交,上层不得重写响应体)") + } +} + +// assertContentBlockedNotUpstream 断言非流式安全拦截错误为请求类(4xx)。 +// 不校验 ResponseWritten:非流式在报错前未写出任何字节,上层仍应写标准错误体。 +func assertContentBlockedNotUpstream(t *testing.T, err error) { + t.Helper() + var relayErr *constant.RelayError + if !errors.As(err, &relayErr) { + t.Fatalf("want *constant.RelayError, got %T: %v", err, err) + } + if relayErr.StatusCode >= 500 { + t.Errorf("StatusCode = %d, want 4xx(安全拦截属客户端内容问题,不得罚渠道健康)", relayErr.StatusCode) + } +} + +// claudeInboundInfo 构造 Claude 入站 + Gemini 上游的 RelayInfo +func claudeInboundInfo(stream bool) *common.RelayInfo { + return &common.RelayInfo{ + IsStream: stream, + RequestID: "req123", + InboundFormat: constant.RelayFormatClaude, + ClientFormat: constant.RelayFormatClaude, + OriginModelName: "gemini-3-pro", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderGemini), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gemini-3-pro", + }, + } +} + +// claudeInboundAdaptor 构造已 Init 的适配器与配套 RelayInfo +// (流式处理器会读 a.info 判断 Code Assist 模式,未 Init 会 panic) +func claudeInboundAdaptor(stream bool) (*Adaptor, *common.RelayInfo) { + info := claudeInboundInfo(stream) + a := &Adaptor{} + a.Init(info) + return a, info +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: readCloser(body), + } +} + +func readCloser(s string) *nopCloser { + return &nopCloser{Reader: strings.NewReader(s)} +} + +type nopCloser struct{ *strings.Reader } + +func (n *nopCloser) Close() error { return nil } + +// sseEvent 从记录的 SSE 输出中解析出的一个事件 +type sseEvent struct { + Event string + Data map[string]any +} + +// parseClaudeSSE 解析 Claude 格式 SSE 输出为有序事件列表 +func parseClaudeSSE(t *testing.T, raw string) []sseEvent { + t.Helper() + var events []sseEvent + for frame := range strings.SplitSeq(raw, "\n\n") { + frame = strings.TrimSpace(frame) + if frame == "" { + continue + } + var ev sseEvent + for line := range strings.SplitSeq(frame, "\n") { + switch { + case strings.HasPrefix(line, "event: "): + ev.Event = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + payload := strings.TrimPrefix(line, "data: ") + if payload == "" { + t.Fatalf("SSE 事件 %q 的 data 为空,客户端会 JSON.parse 失败", ev.Event) + } + if err := json.Unmarshal([]byte(payload), &ev.Data); err != nil { + t.Fatalf("解析 SSE data 失败 (event=%s): %v\ndata=%s", ev.Event, err, payload) + } + } + } + if ev.Event != "" { + events = append(events, ev) + } + } + return events +} + +func eventTypes(events []sseEvent) []string { + types := make([]string, 0, len(events)) + for _, ev := range events { + types = append(types, ev.Event) + } + return types +} + +// ===== 非流式 ===== + +// TestGeminiToClaudeResponse_PreservesPartOrder thinking/text 块按 Gemini parts 原始顺序映射, +// thoughtSignature 落到 Claude 的 signature 字段。 +func TestGeminiToClaudeResponse_PreservesPartOrder(t *testing.T) { + thought := true + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "让我想想", Thought: &thought, ThoughtSignature: "sig-abc"}, + {Text: "答案是 42"}, + }, + }, + FinishReason: "STOP", + }}, + UsageMetadata: &dto.GeminiUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 20, + ThoughtsTokenCount: 5, + TotalTokenCount: 125, + }, + } + + got := geminiToClaudeResponse(geminiResp, claudeInboundInfo(false)) + + if got.Type != "message" || got.Role != "assistant" { + t.Errorf("消息骨架不对: type=%q role=%q", got.Type, got.Role) + } + if len(got.Content) != 2 { + t.Fatalf("content 块数 = %d, want 2: %+v", len(got.Content), got.Content) + } + if got.Content[0].Type != "thinking" { + t.Errorf("首块应为 thinking, got %q", got.Content[0].Type) + } + if got.Content[0].Thinking == nil || *got.Content[0].Thinking != "让我想想" { + t.Errorf("thinking 内容不对: %+v", got.Content[0].Thinking) + } + if got.Content[0].Signature != "sig-abc" { + t.Errorf("thoughtSignature 未搬运到 signature: %q", got.Content[0].Signature) + } + if got.Content[1].Type != "text" || got.Content[1].Text == nil || *got.Content[1].Text != "答案是 42" { + t.Errorf("次块应为 text: %+v", got.Content[1]) + } + if got.StopReason != common.ClaudeEndTurn { + t.Errorf("stop_reason = %q, want %q", got.StopReason, common.ClaudeEndTurn) + } +} + +// TestGeminiToClaudeResponse_ToolUse functionCall 合成 tool_use ID, +// 且 stop_reason 必须从 Gemini 的 STOP 改写为 Claude 的 tool_use,否则客户端不会发起工具回合。 +func TestGeminiToClaudeResponse_ToolUse(t *testing.T) { + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: "get_weather", + Arguments: map[string]any{"city": "北京"}, + }}, + {FunctionCall: &dto.GeminiFunctionCall{FunctionName: "get_time"}}, + }, + }, + FinishReason: "STOP", + }}, + } + + got := geminiToClaudeResponse(geminiResp, claudeInboundInfo(false)) + + if len(got.Content) != 2 { + t.Fatalf("content 块数 = %d, want 2", len(got.Content)) + } + first, second := got.Content[0], got.Content[1] + if first.Type != "tool_use" || first.Name != "get_weather" { + t.Errorf("首个工具块不对: %+v", first) + } + if first.ID == "" || first.ID == second.ID { + t.Errorf("tool_use ID 必须非空且互不相同: %q / %q", first.ID, second.ID) + } + if !strings.HasPrefix(first.ID, "toolu_") { + t.Errorf("tool_use ID 应为 Claude 风格前缀: %q", first.ID) + } + // Arguments 为 nil 时须回退为空对象,Claude 的 tool_use.input 不允许 null + if second.Input == nil { + t.Errorf("空参数应回退为空对象,got nil") + } + if got.StopReason != common.ClaudeToolUse { + t.Errorf("stop_reason = %q, want %q(Gemini 带 functionCall 时仍返回 STOP,须改写)", + got.StopReason, common.ClaudeToolUse) + } +} + +// TestGeminiToClaudeResponse_EmptyContent Claude 协议要求 content 非空 +func TestGeminiToClaudeResponse_EmptyContent(t *testing.T) { + got := geminiToClaudeResponse(&dto.GeminiChatResponse{}, claudeInboundInfo(false)) + if len(got.Content) != 1 || got.Content[0].Type != "text" { + t.Fatalf("空响应应回退为单个空 text 块: %+v", got.Content) + } +} + +// TestClaudeUsageFromGemini_CacheAndThoughts Claude 口径:input 不含缓存、output 含思考 +func TestClaudeUsageFromGemini_CacheAndThoughts(t *testing.T) { + um := &dto.GeminiUsageMetadata{ + PromptTokenCount: 100, // Gemini 语义已含 cached + CachedContentTokenCount: 30, + CandidatesTokenCount: 20, // Gemini 语义不含 thoughts + ThoughtsTokenCount: 7, + } + + got := claudeUsageFromGemini(um) + + if got.InputTokens != 70 { + t.Errorf("input_tokens = %d, want 70(须扣除 cached)", got.InputTokens) + } + if got.CacheReadInputTokens != 30 { + t.Errorf("cache_read_input_tokens = %d, want 30", got.CacheReadInputTokens) + } + if got.OutputTokens != 27 { + t.Errorf("output_tokens = %d, want 27(candidates+thoughts)", got.OutputTokens) + } +} + +// TestClaudeInputTokens_NegativeGuard cached 超过 prompt 时不得出现负数 +func TestClaudeInputTokens_NegativeGuard(t *testing.T) { + got := claudeInputTokens(&dto.GeminiUsageMetadata{PromptTokenCount: 10, CachedContentTokenCount: 40}) + if got != 0 { + t.Errorf("input_tokens = %d, want 0", got) + } + if claudeInputTokens(nil) != 0 { + t.Errorf("nil usage 应返回 0") + } +} + +// TestGeminiPartFallbackText Claude 无对应块类型的 part 渲染为文本而非丢弃 +func TestGeminiPartFallbackText(t *testing.T) { + cases := []struct { + name string + part dto.GeminiPart + want string + }{ + {"inlineData", dto.GeminiPart{InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "AAA"}}, + "![image](data:image/png;base64,AAA)"}, + {"fileData", dto.GeminiPart{FileData: &dto.GeminiFileData{FileURI: "gs://b/o"}}, "[file](gs://b/o)"}, + {"executableCode", dto.GeminiPart{ExecutableCode: &dto.GeminiExecutableCode{Language: "python", Code: "print(1)"}}, + "```python\nprint(1)\n```"}, + {"codeExecutionResult", dto.GeminiPart{CodeExecutionResult: &dto.GeminiCodeExecutionResult{Outcome: "OUTCOME_OK", Output: "1"}}, + "Execution OUTCOME_OK:\n1"}, + {"纯文本无回退", dto.GeminiPart{Text: "hi"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := geminiPartFallbackText(&tc.part); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// TestHandleNonStreamToClaude_WritesClaudeBody 正常路径:写出的响应体为 Claude Messages 结构, +// 而计费用量仍为 Gemini 口径(两套口径不能混)。 +func TestHandleNonStreamToClaude_WritesClaudeBody(t *testing.T) { + body := `{"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]},"finishReason":"STOP"}],` + + `"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}` + + a, info := claudeInboundAdaptor(false) + rec := httptest.NewRecorder() + + usage, err := a.handleNonStreamToClaude(context.Background(), jsonResponse(http.StatusOK, body), info, rec) + if err != nil { + t.Fatalf("handleNonStreamToClaude error: %v", err) + } + + var got dto.ClaudeResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("响应体不是合法 Claude JSON: %v\nbody=%s", err, rec.Body.String()) + } + if got.Type != "message" || got.Role != "assistant" { + t.Errorf("消息骨架不对: %+v", got) + } + if len(got.Content) != 1 || got.Content[0].Type != "text" || *got.Content[0].Text != "你好" { + t.Errorf("content 不对: %+v", got.Content) + } + if got.StopReason != common.ClaudeEndTurn { + t.Errorf("stop_reason = %q", got.StopReason) + } + // 客户端可见用量为 Claude 口径 + if got.Usage.InputTokens != 40 || got.Usage.OutputTokens != 11 || got.Usage.CacheReadInputTokens != 10 { + t.Errorf("客户端用量口径不对: %+v", got.Usage) + } + // 计费用量为 Gemini 口径(prompt 含 cached) + if usage.PromptTokens != 50 || usage.CompletionTokens != 11 || !usage.CacheIncludedInPrompt { + t.Errorf("计费用量口径不对: %+v", usage) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} + +// TestHandleNonStreamToClaude_SafetyBlock 安全过滤命中时返回请求类错误且不写响应体 +func TestHandleNonStreamToClaude_SafetyBlock(t *testing.T) { + a, info := claudeInboundAdaptor(false) + rec := httptest.NewRecorder() + body := `{"promptFeedback":{"blockReason":"SAFETY"}}` + + _, err := a.handleNonStreamToClaude(context.Background(), jsonResponse(http.StatusOK, body), info, rec) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + // 非流式尚未写出字节,故不置 ResponseWritten;但分类同流式:4xx 而非上游 5xx + assertContentBlockedNotUpstream(t, err) + if rec.Body.Len() != 0 { + t.Errorf("不应写响应体, got %q", rec.Body.String()) + } +} + +// TestHandleNonStreamToClaude_InvalidBody 上游返回非法 JSON 时报错而非写出半成品 +func TestHandleNonStreamToClaude_InvalidBody(t *testing.T) { + a, info := claudeInboundAdaptor(false) + rec := httptest.NewRecorder() + + _, err := a.handleNonStreamToClaude(context.Background(), jsonResponse(http.StatusOK, "not-json"), info, rec) + if err == nil { + t.Fatal("非法响应体应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("不应写响应体, got %q", rec.Body.String()) + } +} + +// TestHandleNonStreamToClaude_UpstreamErrorNotWritten 上游错误不在本层写响应, +// 交上层 WriteClaudeRelayError 统一写入(避免双写与重试时污染响应)。 +func TestHandleNonStreamToClaude_UpstreamErrorNotWritten(t *testing.T) { + a, info := claudeInboundAdaptor(false) + rec := httptest.NewRecorder() + body := `{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","message":"quota"}}` + + _, err := a.handleNonStreamToClaude(context.Background(), jsonResponse(http.StatusTooManyRequests, body), info, rec) + + if err == nil { + t.Fatal("上游 429 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} + +// ===== 流式 ===== + +// TestHandleStreamToClaude_EventSequence 完整事件序列: +// message_start → content_block_start/delta/stop(thinking 与 text 各成块)→ message_delta → message_stop +func TestHandleStreamToClaude_EventSequence(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"思考中","thought":true,"thoughtSignature":"sig1"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"世界"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}`, + ``, + ``, + }, "\n") + + a, info := claudeInboundAdaptor(true) + rec := httptest.NewRecorder() + + usage, err := a.handleStreamToClaude(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleStreamToClaude error: %v", err) + } + + events := parseClaudeSSE(t, rec.Body.String()) + got := eventTypes(events) + want := []string{ + "message_start", + "content_block_start", "content_block_delta", "content_block_delta", // thinking 块 + 文本增量 + signature 增量 + "content_block_stop", + "content_block_start", "content_block_delta", "content_block_delta", // text 块 + 两次增量 + "content_block_stop", + "message_delta", "message_stop", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // thinking 块与 signature + if events[1].Data["content_block"].(map[string]any)["type"] != "thinking" { + t.Errorf("首块应为 thinking: %+v", events[1].Data["content_block"]) + } + if events[3].Data["delta"].(map[string]any)["type"] != "signature_delta" { + t.Errorf("应发出 signature_delta: %+v", events[3].Data["delta"]) + } + + // 块索引必须连续且成对:thinking=0, text=1 + if idx := events[4].Data["index"].(float64); idx != 0 { + t.Errorf("thinking 块 content_block_stop index = %v, want 0", idx) + } + if idx := events[5].Data["index"].(float64); idx != 1 { + t.Errorf("text 块 content_block_start index = %v, want 1", idx) + } + + // message_delta 的客户端可见用量为 Claude 口径 + delta := events[9].Data + if delta["delta"].(map[string]any)["stop_reason"] != common.ClaudeEndTurn { + t.Errorf("stop_reason = %v, want %q", delta["delta"], common.ClaudeEndTurn) + } + clientUsage := delta["usage"].(map[string]any) + if clientUsage["input_tokens"].(float64) != 40 { + t.Errorf("客户端 input_tokens = %v, want 40(50-10 cached)", clientUsage["input_tokens"]) + } + if clientUsage["output_tokens"].(float64) != 11 { + t.Errorf("客户端 output_tokens = %v, want 11(8+3 thoughts)", clientUsage["output_tokens"]) + } + + // 计费用量走 Gemini 口径(completion 含 thoughts,prompt 含 cached) + if usage.PromptTokens != 50 || usage.CompletionTokens != 11 { + t.Errorf("计费用量 = prompt %d / completion %d, want 50 / 11", usage.PromptTokens, usage.CompletionTokens) + } + if !usage.CacheIncludedInPrompt { + t.Error("CacheIncludedInPrompt 应为 true,否则缓存部分会按 input 全价重复计费") + } +} + +// TestHandleStreamToClaude_ToolCall functionCall 转为独立 tool_use 块, +// 参数以 input_json_delta 一次性给出,stop_reason 改写为 tool_use。 +func TestHandleStreamToClaude_ToolCall(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"北京"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}`, + ``, + ``, + }, "\n") + + a, info := claudeInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToClaude(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleStreamToClaude error: %v", err) + } + + events := parseClaudeSSE(t, rec.Body.String()) + got := eventTypes(events) + want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + block := events[1].Data["content_block"].(map[string]any) + if block["type"] != "tool_use" || block["name"] != "get_weather" { + t.Errorf("tool_use 块不对: %+v", block) + } + if id, _ := block["id"].(string); !strings.HasPrefix(id, "toolu_") { + t.Errorf("tool_use id = %q, want toolu_ 前缀", id) + } + + delta := events[2].Data["delta"].(map[string]any) + if delta["type"] != "input_json_delta" { + t.Fatalf("参数应以 input_json_delta 给出: %+v", delta) + } + var args map[string]any + if err := json.Unmarshal([]byte(delta["partial_json"].(string)), &args); err != nil { + t.Fatalf("partial_json 不是合法 JSON: %v", err) + } + if args["city"] != "北京" { + t.Errorf("参数丢失: %+v", args) + } + + if events[4].Data["delta"].(map[string]any)["stop_reason"] != common.ClaudeToolUse { + t.Errorf("stop_reason 应改写为 tool_use: %+v", events[4].Data["delta"]) + } +} + +// TestHandleStreamToClaude_EmptyUpstreamStillClosesSequence 上游无有效 chunk 时, +// 仍须补齐 message_start/message_stop,否则 Anthropic 客户端会一直等收尾事件。 +func TestHandleStreamToClaude_EmptyUpstreamStillClosesSequence(t *testing.T) { + a, info := claudeInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToClaude(context.Background(), sseResponse(strings.NewReader("\n\n")), info, rec) + if err != nil { + t.Fatalf("handleStreamToClaude error: %v", err) + } + + got := eventTypes(parseClaudeSSE(t, rec.Body.String())) + want := []string{"message_start", "message_delta", "message_stop"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } +} + +// TestHandleStreamToClaude_UpstreamErrorNotWritten 流式上游错误同样不在本层写响应 +func TestHandleStreamToClaude_UpstreamErrorNotWritten(t *testing.T) { + a, info := claudeInboundAdaptor(true) + rec := httptest.NewRecorder() + resp := jsonResponse(http.StatusServiceUnavailable, `{"error":{"code":503,"status":"UNAVAILABLE","message":"down"}}`) + + _, err := a.handleStreamToClaude(context.Background(), resp, info, rec) + + if err == nil { + t.Fatal("上游 503 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} + +// TestHandleStreamToClaude_SafetyBlockClosesSequence 安全过滤命中时 SSE 头已发出, +// 必须补齐收尾事件(否则客户端挂起)并向上返回错误。 +// 安全拦截属客户端内容问题:错误须为请求类(4xx),不得按上游故障罚渠道健康; +// 且带 ResponseWritten(SSE 头已提交,上层不得重写响应体或换渠道重试)。 +func TestHandleStreamToClaude_SafetyBlockClosesSequence(t *testing.T) { + upstream := "data: {\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n" + + a, info := claudeInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToClaude(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + assertContentBlockedError(t, err) + if got := info.StreamStatus.GetEndReason(); got != common.StreamEndReasonError { + t.Errorf("StreamStatus end reason = %v, want %v", got, common.StreamEndReasonError) + } + + got := eventTypes(parseClaudeSSE(t, rec.Body.String())) + if len(got) == 0 || got[len(got)-1] != "message_stop" { + t.Errorf("必须以 message_stop 收尾, got %v", got) + } +} diff --git a/relay/channel/gemini/converter.go b/relay/channel/gemini/converter.go deleted file mode 100644 index c08a425..0000000 --- a/relay/channel/gemini/converter.go +++ /dev/null @@ -1,686 +0,0 @@ -package gemini - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/qianfree/team-api/relay/channel/openai" - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" -) - -// ConvertOpenAIToGemini 将 OpenAI 格式请求转换为 Gemini API 格式。 -func ConvertOpenAIToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - var openaiReq dto.GeneralOpenAIRequest - if err := json.Unmarshal(requestBody, &openaiReq); err != nil { - return nil, fmt.Errorf("parse openai request: %w", err) - } - - geminiReq := dto.GeminiChatRequest{ - Contents: make([]dto.GeminiContent, 0, len(openaiReq.Messages)), - GenerationConfig: &dto.GeminiGenerationConfig{ - Temperature: openaiReq.Temperature, - TopP: openaiReq.TopP, - }, - } - - // topK - if openaiReq.TopK != nil && *openaiReq.TopK > 0 { - v := float64(*openaiReq.TopK) - geminiReq.GenerationConfig.TopK = &v - } - - // maxOutputTokens - maxTokens := 0 - if openaiReq.MaxTokens != nil { - maxTokens = int(*openaiReq.MaxTokens) - } else if openaiReq.MaxCompletionTokens != nil { - maxTokens = int(*openaiReq.MaxCompletionTokens) - } - if maxTokens > 0 { - geminiReq.GenerationConfig.MaxOutputTokens = o2gUintPtr(uint(maxTokens)) - } - - // stopSequences - if stops := o2gParseStopSequences(openaiReq.Stop); len(stops) > 0 { - if len(stops) > 5 { - stops = stops[:5] - } - geminiReq.GenerationConfig.StopSequences = stops - } - - // seed - if openaiReq.Seed != nil { - geminiReq.GenerationConfig.Seed = o2gInt64Ptr(int64(*openaiReq.Seed)) - } - - // presencePenalty - if openaiReq.PresencePenalty != nil { - geminiReq.GenerationConfig.PresencePenalty = openaiReq.PresencePenalty - } - - // frequencyPenalty - if openaiReq.FrequencyPenalty != nil { - geminiReq.GenerationConfig.FrequencyPenalty = openaiReq.FrequencyPenalty - } - - // n → candidateCount - if openaiReq.N != nil && *openaiReq.N > 0 { - n := *openaiReq.N - geminiReq.GenerationConfig.CandidateCount = &n - } - - // logprobs - if openaiReq.LogProbs != nil && *openaiReq.LogProbs { - geminiReq.GenerationConfig.ResponseLogprobs = openaiReq.LogProbs - } - if openaiReq.TopLogProbs != nil && *openaiReq.TopLogProbs > 0 { - v := *openaiReq.TopLogProbs - geminiReq.GenerationConfig.Logprobs = &v - } - - // serviceTier - if openaiReq.ServiceTier != "" { - geminiReq.ServiceTier = openaiReq.ServiceTier - } - - // reasoning_effort → thinkingConfig - if openaiReq.ReasoningEffort != "" { - geminiReq.GenerationConfig.ThinkingConfig = o2gConvertReasoningEffort(openaiReq.ReasoningEffort) - } - - // response_format → responseMimeType + responseSchema - if openaiReq.ResponseFormat != nil { - if openaiReq.ResponseFormat.Type == "json_schema" || openaiReq.ResponseFormat.Type == "json_object" { - geminiReq.GenerationConfig.ResponseMimeType = "application/json" - if openaiReq.ResponseFormat.JSONSchema != nil { - geminiReq.GenerationConfig.ResponseSchema = o2gConvertResponseSchema(openaiReq.ResponseFormat.JSONSchema) - } - } - } - - // Chat 内生图:为支持图片输出的模型注入 ResponseModalities - if isGeminiImageModel(info.ChannelMeta.UpstreamModelName) { - geminiReq.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"} - // 透传 imageConfig(宽高比、分辨率) - if openaiReq.ImageConfig != nil { - geminiReq.GenerationConfig.ImageConfig = &dto.GeminiImageConfig{ - AspectRatio: openaiReq.ImageConfig.AspectRatio, - ImageSize: openaiReq.ImageConfig.ImageSize, - } - } - } - - // 默认安全设置(较宽松,避免过度过滤) - geminiReq.SafetySettings = []dto.GeminiSafetySetting{ - {Category: "HARM_CATEGORY_HARASSMENT", Threshold: "BLOCK_ONLY_HIGH"}, - {Category: "HARM_CATEGORY_HATE_SPEECH", Threshold: "BLOCK_ONLY_HIGH"}, - {Category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", Threshold: "BLOCK_ONLY_HIGH"}, - {Category: "HARM_CATEGORY_DANGEROUS_CONTENT", Threshold: "BLOCK_ONLY_HIGH"}, - } - - // tools 转换 - if len(openaiReq.Tools) > 0 { - geminiTools, err := o2gConvertTools(openaiReq.Tools) - if err != nil { - return nil, fmt.Errorf("convert tools: %w", err) - } - if len(geminiTools) > 0 { - toolsJSON, err := json.Marshal(geminiTools) - if err != nil { - return nil, fmt.Errorf("marshal gemini tools: %w", err) - } - geminiReq.Tools = toolsJSON - } - - // tool_choice 转换 - if openaiReq.ToolChoice != nil { - geminiReq.ToolConfig = o2gConvertToolChoice(openaiReq.ToolChoice) - } - } - - // messages 转换 - toolCallIDs := make(map[string]string) // toolCallID -> functionName - var systemParts []dto.GeminiPart - - for _, msg := range openaiReq.Messages { - switch msg.Role { - case "system", "developer": - text := o2gExtractText(msg.Content) - if text != "" { - systemParts = append(systemParts, dto.GeminiPart{Text: text}) - } - - case "user": - parts := o2gConvertUserParts(msg.Content) - if len(parts) > 0 { - geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{ - Role: "user", - Parts: parts, - }) - } - - case "assistant": - parts := o2gConvertAssistantParts(msg, toolCallIDs) - if len(parts) > 0 { - geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{ - Role: "model", - Parts: parts, - }) - } - - case "tool": - // 确保最后一个 content 是 user(Gemini 要求 functionResponse 在 user content 中) - if len(geminiReq.Contents) == 0 || geminiReq.Contents[len(geminiReq.Contents)-1].Role == "model" { - geminiReq.Contents = append(geminiReq.Contents, dto.GeminiContent{Role: "user"}) - } - lastIdx := len(geminiReq.Contents) - 1 - - name := msg.Name - if name == "" { - name = toolCallIDs[msg.ToolCallID] - } - - contentStr := o2gExtractText(msg.Content) - var response any = contentStr - if contentStr != "" { - var parsed any - if json.Unmarshal([]byte(contentStr), &parsed) == nil { - response = parsed - } - } - - geminiReq.Contents[lastIdx].Parts = append(geminiReq.Contents[lastIdx].Parts, dto.GeminiPart{ - FunctionResponse: &dto.GeminiFunctionResponse{ - Name: name, - Response: response, - }, - }) - } - } - - // 系统指令 - if len(systemParts) > 0 { - geminiReq.SystemInstruction = &dto.GeminiContent{ - Parts: systemParts, - } - } - - result, err := json.Marshal(geminiReq) - if err != nil { - return nil, fmt.Errorf("marshal gemini request: %w", err) - } - return bytes.NewReader(result), nil -} - -// ConvertClaudeToGemini 将 Claude 格式请求转换为 Gemini 格式。 -// 链式转换:Claude → OpenAI → Gemini -func ConvertClaudeToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - openaiReader, err := openai.ConvertClaudeToOpenAI(requestBody, info) - if err != nil { - return nil, fmt.Errorf("claude to openai: %w", err) - } - openaiBody, err := io.ReadAll(openaiReader) - if err != nil { - return nil, fmt.Errorf("read openai intermediate: %w", err) - } - return ConvertOpenAIToGemini(openaiBody, info) -} - -// ConvertResponsesToGemini 将 Responses API 格式请求转换为 Gemini 格式。 -// 链式转换:Responses → OpenAI → Gemini -func ConvertResponsesToGemini(requestBody []byte, info *common.RelayInfo) (io.Reader, error) { - openaiReader, err := openai.ConvertResponsesToOpenAI(requestBody, info) - if err != nil { - return nil, fmt.Errorf("responses to openai: %w", err) - } - openaiBody, err := io.ReadAll(openaiReader) - if err != nil { - return nil, fmt.Errorf("read openai intermediate: %w", err) - } - return ConvertOpenAIToGemini(openaiBody, info) -} - -// ===== OpenAI → Gemini 内部工具函数 ===== - -// o2gGeminiTool Gemini 工具定义(内部序列化用) -type o2gGeminiTool struct { - FunctionDeclarations []o2gFunctionDecl `json:"functionDeclarations,omitempty"` -} - -// o2gFunctionDecl Gemini 函数声明 -type o2gFunctionDecl struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Parameters any `json:"parameters,omitempty"` -} - -func o2gConvertTools(tools []dto.Tool) ([]o2gGeminiTool, error) { - var funcDecls []o2gFunctionDecl - for _, t := range tools { - if t.Type != "function" { - continue - } - cleanedParams := o2gCleanParams(t.Function.Parameters) - funcDecls = append(funcDecls, o2gFunctionDecl{ - Name: t.Function.Name, - Description: t.Function.Description, - Parameters: cleanedParams, - }) - } - if len(funcDecls) == 0 { - return nil, nil - } - return []o2gGeminiTool{{FunctionDeclarations: funcDecls}}, nil -} - -// o2gCleanParams 清理函数参数,移除 Gemini 不支持的字段 -func o2gCleanParams(params any) any { - if params == nil { - return nil - } - m, ok := params.(map[string]any) - if !ok { - return params - } - cleaned := make(map[string]any) - for k, v := range m { - switch k { - case "type", "description", "properties", "required", "items", - "anyOf", "default", "enum", "format", "maxLength", "minLength", - "maximum", "minimum", "pattern", "title", "nullable", - "maxItems", "minItems", "maxProperties", "minProperties", "example": - cleaned[k] = v - } - } - return cleaned -} - -func o2gConvertToolChoice(toolChoice any) any { - if toolChoice == nil { - return nil - } - switch v := toolChoice.(type) { - case string: - switch v { - case "auto": - return map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}} - case "none": - return map[string]any{"functionCallingConfig": map[string]any{"mode": "NONE"}} - case "required": - return map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY"}} - default: - return map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}} - } - case map[string]any: - if v["type"] == "function" { - config := map[string]any{ - "functionCallingConfig": map[string]any{"mode": "ANY"}, - } - if fn, ok := v["function"].(map[string]any); ok { - if name, ok := fn["name"].(string); ok && name != "" { - config["functionCallingConfig"].(map[string]any)["allowedFunctionNames"] = []string{name} - } - } - return config - } - } - return nil -} - -func o2gParseStopSequences(stop any) []string { - if stop == nil { - return nil - } - switch v := stop.(type) { - case string: - if v != "" { - return []string{v} - } - case []string: - return v - case []any: - var result []string - for _, item := range v { - if s, ok := item.(string); ok && s != "" { - result = append(result, s) - } - } - return result - } - return nil -} - -func o2gExtractText(content any) string { - switch v := content.(type) { - case string: - return v - case []any: - var parts []string - for _, item := range v { - if m, ok := item.(map[string]any); ok { - if m["type"] == "text" { - if t, ok := m["text"].(string); ok { - parts = append(parts, t) - } - } - } - } - return strings.Join(parts, "\n") - default: - return "" - } -} - -func o2gConvertUserParts(content any) []dto.GeminiPart { - switch v := content.(type) { - case string: - if v == "" { - return nil - } - return o2gParseMarkdownImages(v) - case []any: - var parts []dto.GeminiPart - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - continue - } - switch m["type"] { - case "text": - if text, ok := m["text"].(string); ok && text != "" { - parts = append(parts, o2gParseMarkdownImages(text)...) - } - case "image_url": - if imageURL, ok := m["image_url"].(map[string]any); ok { - if url, ok := imageURL["url"].(string); ok { - if mimeType, data, ok := o2gParseDataURL(url); ok { - parts = append(parts, dto.GeminiPart{ - InlineData: &dto.GeminiInlineData{ - MimeType: mimeType, - Data: data, - }, - }) - } - } - } - case "input_audio": - if audioData, ok := m["input_audio"].(map[string]any); ok { - if data, ok := audioData["data"].(string); ok { - mimeType := "audio/wav" - if fmt, ok := audioData["format"].(string); ok && fmt != "" { - mimeType = "audio/" + fmt - } - parts = append(parts, dto.GeminiPart{ - InlineData: &dto.GeminiInlineData{ - MimeType: mimeType, - Data: data, - }, - }) - } - } - case "file": - if fileData, ok := m["file"].(map[string]any); ok { - if url, ok := fileData["url"].(string); ok { - mimeType := "application/octet-stream" - if mt, ok := fileData["mime_type"].(string); ok && mt != "" { - mimeType = mt - } - parts = append(parts, dto.GeminiPart{ - FileData: &dto.GeminiFileData{ - MimeType: mimeType, - FileURI: url, - }, - }) - } - } - } - } - return parts - default: - return nil - } -} - -func o2gParseMarkdownImages(text string) []dto.GeminiPart { - var parts []dto.GeminiPart - - for { - startIdx := strings.Index(text, "![") - if startIdx == -1 { - break - } - bracketIdx := strings.Index(text[startIdx:], "](data:") - if bracketIdx == -1 { - break - } - bracketIdx += startIdx - closeIdx := strings.Index(text[bracketIdx+2:], ")") - if closeIdx == -1 { - break - } - closeIdx += bracketIdx + 2 - - if startIdx > 0 { - before := text[:startIdx] - if before != "" { - parts = append(parts, dto.GeminiPart{Text: before}) - } - } - - dataURL := text[bracketIdx+2 : closeIdx] - if mimeType, data, ok := o2gParseDataURL(dataURL); ok { - parts = append(parts, dto.GeminiPart{ - InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: data}, - }) - } - - text = text[closeIdx+1:] - } - - if len(parts) == 0 { - if text != "" { - return []dto.GeminiPart{{Text: text}} - } - return nil - } - if text != "" { - parts = append(parts, dto.GeminiPart{Text: text}) - } - return parts -} - -func o2gParseDataURL(dataURL string) (mimeType, data string, ok bool) { - if len(dataURL) < 11 || dataURL[:5] != "data:" { - return "", "", false - } - rest := dataURL[5:] - semiIdx := strings.Index(rest, ";") - if semiIdx == -1 { - return "", "", false - } - mimeType = rest[:semiIdx] - afterSemi := rest[semiIdx+1:] - if len(afterSemi) < 7 || afterSemi[:7] != "base64," { - return "", "", false - } - data = afterSemi[7:] - return mimeType, data, true -} - -func o2gConvertAssistantParts(msg dto.Message, toolCallIDs map[string]string) []dto.GeminiPart { - var parts []dto.GeminiPart - - text := o2gExtractText(msg.Content) - if text != "" { - parts = append(parts, dto.GeminiPart{Text: text}) - } - - // reasoning_content → thought(用于多轮对话) - if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { - t := true - parts = append(parts, dto.GeminiPart{ - Text: *msg.ReasoningContent, - Thought: &t, - }) - } - - for _, tc := range msg.ToolCalls { - args := map[string]any{} - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{"raw": tc.Function.Arguments} - } - } - parts = append(parts, dto.GeminiPart{ - FunctionCall: &dto.GeminiFunctionCall{ - FunctionName: tc.Function.Name, - Arguments: args, - }, - }) - toolCallIDs[tc.ID] = tc.Function.Name - } - - return parts -} - -func o2gUintPtr(v uint) *uint { return &v } -func o2gInt64Ptr(v int64) *int64 { return &v } - -// o2gConvertReasoningEffort 将 OpenAI reasoning_effort 转换为 Gemini thinkingConfig -func o2gConvertReasoningEffort(effort string) *dto.GeminiThinkingConfig { - var budget int - var level string - switch effort { - case "low": - budget = 1024 - level = "LOW" - case "medium": - budget = 8192 - level = "MEDIUM" - case "high": - budget = 32768 - level = "HIGH" - default: - budget = 8192 - level = "MEDIUM" - } - return &dto.GeminiThinkingConfig{ - IncludeThoughts: true, - ThoughtBudget: &budget, - ThinkingLevel: level, - } -} - -// o2gConvertResponseSchema 将 OpenAI 的 JSON Schema 转换为 Gemini Schema 格式。 -// OpenAI 使用小写类型名 ("string"),Gemini 使用大写 ("STRING")。 -// 还处理 OpenAI 的 json_schema 包装格式:{"type":"json_schema","json_schema":{"schema":{...}}} -func o2gConvertResponseSchema(schema any) any { - if schema == nil { - return nil - } - - // 处理 json_schema 包装格式 - if m, ok := schema.(map[string]any); ok { - // 检查是否是 {"type":"json_schema","json_schema":{"schema":{...}}} 格式 - if js, ok := m["json_schema"].(map[string]any); ok { - if innerSchema, ok := js["schema"]; ok { - return o2gConvertSchemaMap(innerSchema) - } - } - return o2gConvertSchemaMap(m) - } - - return schema -} - -// o2gConvertSchemaMap 递归转换 JSON Schema 类型名为 Gemini 格式 -func o2gConvertSchemaMap(schema any) any { - m, ok := schema.(map[string]any) - if !ok { - return schema - } - - result := make(map[string]any, len(m)) - for k, v := range m { - switch k { - case "type": - if s, ok := v.(string); ok { - result["type"] = o2gMapSchemaType(s) - } else { - result[k] = v - } - case "properties": - if props, ok := v.(map[string]any); ok { - converted := make(map[string]any, len(props)) - for pk, pv := range props { - converted[pk] = o2gConvertSchemaMap(pv) - } - result["properties"] = converted - } else { - result[k] = v - } - case "items": - result["items"] = o2gConvertSchemaMap(v) - case "anyOf", "oneOf", "allOf": - if arr, ok := v.([]any); ok { - converted := make([]any, len(arr)) - for i, item := range arr { - converted[i] = o2gConvertSchemaMap(item) - } - result[k] = converted - } else { - result[k] = v - } - default: - result[k] = v - } - } - return result -} - -// o2gMapSchemaType 将 JSON Schema 类型名映射为 Gemini Schema 类型名 -func o2gMapSchemaType(t string) string { - switch t { - case "string": - return "STRING" - case "number": - return "NUMBER" - case "integer": - return "INTEGER" - case "boolean": - return "BOOLEAN" - case "object": - return "OBJECT" - case "array": - return "ARRAY" - case "null": - return "NULL" - default: - return t - } -} - -// isGeminiImageModel 检测模型是否支持 Chat 内生图,需要注入 ResponseModalities -func isGeminiImageModel(model string) bool { - imageModels := []string{ - "gemini-2.0-flash-exp-image-generation", - "gemini-2.0-flash-exp", - "gemini-3-pro-image-preview", - "gemini-2.5-flash-image", - "gemini-3.1-flash-image-preview", - // Nano Banana 别名 - "nano-banana", - "nano-banana-2-preview", - "nano-banana-pro-preview", - } - for _, m := range imageModels { - if model == m || strings.HasPrefix(model, m+"-") { - return true - } - } - return false -} diff --git a/relay/channel/gemini/converter_test.go b/relay/channel/gemini/converter_test.go deleted file mode 100644 index 2768171..0000000 --- a/relay/channel/gemini/converter_test.go +++ /dev/null @@ -1,807 +0,0 @@ -package gemini - -import ( - "encoding/json" - "io" - "testing" - - "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" -) - -func makeTestInfo() *common.RelayInfo { - return &common.RelayInfo{ - ChannelMeta: &common.ChannelMeta{ - UpstreamModelName: "gemini-2.5-flash", - }, - InboundFormat: "openai", - } -} - -func TestConvertOpenAIToGemini_SimpleChat(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - Messages: []dto.Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hello"}, - {Role: "assistant", Content: "Hi there!"}, - {Role: "user", Content: "How are you?"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if geminiReq.SystemInstruction == nil { - t.Fatal("expected SystemInstruction to be set") - } - if len(geminiReq.SystemInstruction.Parts) != 1 || geminiReq.SystemInstruction.Parts[0].Text != "You are helpful." { - t.Errorf("expected system instruction 'You are helpful.', got %v", geminiReq.SystemInstruction) - } - - if len(geminiReq.Contents) != 3 { - t.Fatalf("expected 3 contents, got %d", len(geminiReq.Contents)) - } - if geminiReq.Contents[0].Role != "user" { - t.Errorf("expected first content role 'user', got %s", geminiReq.Contents[0].Role) - } - if geminiReq.Contents[1].Role != "model" { - t.Errorf("expected second content role 'model', got %s", geminiReq.Contents[1].Role) - } - if geminiReq.Contents[2].Role != "user" { - t.Errorf("expected third content role 'user', got %s", geminiReq.Contents[2].Role) - } -} - -func TestConvertOpenAIToGemini_ToolUse(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - Messages: []dto.Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - ToolCalls: []dto.ToolCall{ - { - ID: "call_123", - Type: "function", - Function: dto.FunctionCall{ - Name: "get_weather", - Arguments: `{"location":"NYC"}`, - }, - }, - }, - }, - {Role: "tool", ToolCallID: "call_123", Content: "Sunny, 72°F"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if len(geminiReq.Contents) != 3 { - t.Fatalf("expected 3 contents, got %d", len(geminiReq.Contents)) - } - - assistantContent := geminiReq.Contents[1] - if assistantContent.Role != "model" { - t.Errorf("expected role 'model', got %s", assistantContent.Role) - } - hasFunctionCall := false - for _, part := range assistantContent.Parts { - if part.FunctionCall != nil { - hasFunctionCall = true - if part.FunctionCall.FunctionName != "get_weather" { - t.Errorf("expected function name 'get_weather', got %s", part.FunctionCall.FunctionName) - } - } - } - if !hasFunctionCall { - t.Error("expected function call in assistant content") - } - - toolContent := geminiReq.Contents[2] - if toolContent.Role != "user" { - t.Errorf("expected role 'user' for tool result, got %s", toolContent.Role) - } - hasFunctionResponse := false - for _, part := range toolContent.Parts { - if part.FunctionResponse != nil { - hasFunctionResponse = true - } - } - if !hasFunctionResponse { - t.Error("expected function response in tool content") - } -} - -func TestConvertOpenAIToGemini_GenerationConfig(t *testing.T) { - info := makeTestInfo() - - maxTokens := 1024 - temp := 0.7 - presence := 0.5 - frequency := 0.3 - n := 2 - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - MaxTokens: &maxTokens, - Temperature: &temp, - PresencePenalty: &presence, - FrequencyPenalty: &frequency, - N: &n, - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if geminiReq.GenerationConfig == nil { - t.Fatal("expected GenerationConfig to be set") - } - if geminiReq.GenerationConfig.MaxOutputTokens == nil || *geminiReq.GenerationConfig.MaxOutputTokens != 1024 { - t.Errorf("expected MaxOutputTokens 1024, got %v", geminiReq.GenerationConfig.MaxOutputTokens) - } - if geminiReq.GenerationConfig.Temperature == nil || *geminiReq.GenerationConfig.Temperature != 0.7 { - t.Errorf("expected Temperature 0.7, got %v", geminiReq.GenerationConfig.Temperature) - } - if geminiReq.GenerationConfig.PresencePenalty == nil || *geminiReq.GenerationConfig.PresencePenalty != 0.5 { - t.Errorf("expected PresencePenalty 0.5, got %v", geminiReq.GenerationConfig.PresencePenalty) - } - if geminiReq.GenerationConfig.FrequencyPenalty == nil || *geminiReq.GenerationConfig.FrequencyPenalty != 0.3 { - t.Errorf("expected FrequencyPenalty 0.3, got %v", geminiReq.GenerationConfig.FrequencyPenalty) - } - if geminiReq.GenerationConfig.CandidateCount == nil || *geminiReq.GenerationConfig.CandidateCount != 2 { - t.Errorf("expected CandidateCount 2, got %v", geminiReq.GenerationConfig.CandidateCount) - } -} - -func TestConvertOpenAIToGemini_SafetySettings(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if len(geminiReq.SafetySettings) == 0 { - t.Fatal("expected safety settings to be set") - } -} - -func TestConvertOpenAIToGemini_ReasoningEffort(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - ReasoningEffort: "high", - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if geminiReq.GenerationConfig.ThinkingConfig == nil { - t.Fatal("expected ThinkingConfig to be set") - } - if !geminiReq.GenerationConfig.ThinkingConfig.IncludeThoughts { - t.Error("expected IncludeThoughts to be true") - } - if geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget == nil || *geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget != 32768 { - t.Errorf("expected ThoughtBudget 32768, got %v", geminiReq.GenerationConfig.ThinkingConfig.ThoughtBudget) - } - if geminiReq.GenerationConfig.ThinkingConfig.ThinkingLevel != "HIGH" { - t.Errorf("expected ThinkingLevel HIGH, got %s", geminiReq.GenerationConfig.ThinkingConfig.ThinkingLevel) - } -} - -func TestConvertOpenAIToGemini_ResponseFormat(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - ResponseFormat: &dto.ResponseFormat{ - Type: "json_schema", - JSONSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{"type": "string"}, - "age": map[string]any{"type": "integer"}, - "tags": map[string]any{ - "type": "array", - "items": map[string]any{"type": "string"}, - }, - }, - "required": []any{"name"}, - }, - }, - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if geminiReq.GenerationConfig.ResponseMimeType != "application/json" { - t.Errorf("expected ResponseMimeType 'application/json', got %s", geminiReq.GenerationConfig.ResponseMimeType) - } - - // 验证 schema 类型已转换为大写 - schemaMap, ok := geminiReq.GenerationConfig.ResponseSchema.(map[string]any) - if !ok { - t.Fatalf("expected ResponseSchema to be a map, got %T", geminiReq.GenerationConfig.ResponseSchema) - } - if schemaMap["type"] != "OBJECT" { - t.Errorf("expected top-level type 'OBJECT', got %v", schemaMap["type"]) - } - - props, ok := schemaMap["properties"].(map[string]any) - if !ok { - t.Fatalf("expected properties to be a map, got %T", schemaMap["properties"]) - } - nameField, ok := props["name"].(map[string]any) - if !ok { - t.Fatalf("expected name field to be a map") - } - if nameField["type"] != "STRING" { - t.Errorf("expected name type 'STRING', got %v", nameField["type"]) - } - - ageField, ok := props["age"].(map[string]any) - if !ok { - t.Fatalf("expected age field to be a map") - } - if ageField["type"] != "INTEGER" { - t.Errorf("expected age type 'INTEGER', got %v", ageField["type"]) - } - - tagsField, ok := props["tags"].(map[string]any) - if !ok { - t.Fatalf("expected tags field to be a map") - } - if tagsField["type"] != "ARRAY" { - t.Errorf("expected tags type 'ARRAY', got %v", tagsField["type"]) - } - itemsField, ok := tagsField["items"].(map[string]any) - if !ok { - t.Fatalf("expected items to be a map") - } - if itemsField["type"] != "STRING" { - t.Errorf("expected items type 'STRING', got %v", itemsField["type"]) - } - - // 验证其它字段被保留 - required, ok := schemaMap["required"].([]any) - if !ok || len(required) != 1 || required[0] != "name" { - t.Errorf("expected required to be preserved as ['name'], got %v", schemaMap["required"]) - } -} - -func TestConvertOpenAIToGemini_JsonSchemaWrapper(t *testing.T) { - info := makeTestInfo() - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - ResponseFormat: &dto.ResponseFormat{ - Type: "json_schema", - JSONSchema: map[string]any{ - "type": "json_schema", - "json_schema": map[string]any{ - "name": "person", - "schema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{"type": "string"}, - }, - }, - }, - }, - }, - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - schemaMap, ok := geminiReq.GenerationConfig.ResponseSchema.(map[string]any) - if !ok { - t.Fatalf("expected ResponseSchema to be a map") - } - if schemaMap["type"] != "OBJECT" { - t.Errorf("expected unwrapped schema type 'OBJECT', got %v", schemaMap["type"]) - } -} - -func TestGeminiFinishReasonToOpenAI(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"STOP", "stop"}, - {"MAX_TOKENS", "length"}, - {"SAFETY", "content_filter"}, - {"RECITATION", "content_filter"}, - {"OTHER", "content_filter"}, - {"BLOCKLIST", "content_filter"}, - {"PROHIBITED", "content_filter"}, - {"SPII", "content_filter"}, - {"MALFORMED_FUNCTION_CALL", "tool_calls"}, - {"TOOL_CALLS", "tool_calls"}, - {"LANGUAGE", "content_filter"}, - {"IMAGE_SAFETY", "content_filter"}, - {"IMAGE_PROHIBITED_CONTENT", "content_filter"}, - {"IMAGE_OTHER", "content_filter"}, - {"NO_IMAGE", "content_filter"}, - {"IMAGE_RECITATION", "content_filter"}, - {"UNEXPECTED_TOOL_CALL", "tool_calls"}, - {"TOO_MANY_TOOL_CALLS", "tool_calls"}, - {"MISSING_THOUGHT_SIGNATURE", "stop"}, - {"MALFORMED_RESPONSE", "stop"}, - {"FINISH_REASON_UNSPECIFIED", "stop"}, - {"unknown", "unknown"}, - } - - for _, tt := range tests { - result := common.GeminiFinishReasonToOpenAI(tt.input) - if result != tt.expected { - t.Errorf("GeminiFinishReasonToOpenAI(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestGeminiFinishReasonToClaude(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"STOP", "end_turn"}, - {"MAX_TOKENS", "max_tokens"}, - {"SAFETY", "refusal"}, - {"RECITATION", "refusal"}, - {"LANGUAGE", "refusal"}, - {"IMAGE_SAFETY", "refusal"}, - {"IMAGE_RECITATION", "refusal"}, - {"MALFORMED_FUNCTION_CALL", "tool_use"}, - {"UNEXPECTED_TOOL_CALL", "tool_use"}, - {"TOO_MANY_TOOL_CALLS", "tool_use"}, - {"MISSING_THOUGHT_SIGNATURE", "end_turn"}, - {"MALFORMED_RESPONSE", "end_turn"}, - {"FINISH_REASON_UNSPECIFIED", "end_turn"}, - {"SPII", "refusal"}, - {"unknown", "unknown"}, - } - - for _, tt := range tests { - result := common.GeminiFinishReasonToClaude(tt.input) - if result != tt.expected { - t.Errorf("GeminiFinishReasonToClaude(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestO2gMapSchemaType(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"string", "STRING"}, - {"number", "NUMBER"}, - {"integer", "INTEGER"}, - {"boolean", "BOOLEAN"}, - {"object", "OBJECT"}, - {"array", "ARRAY"}, - {"null", "NULL"}, - {"custom", "custom"}, - } - for _, tt := range tests { - result := o2gMapSchemaType(tt.input) - if result != tt.expected { - t.Errorf("o2gMapSchemaType(%q) = %q, want %q", tt.input, result, tt.expected) - } - } -} - -func TestO2gConvertReasoningEffort(t *testing.T) { - tests := []struct { - effort string - wantBudget int - wantLevel string - }{ - {"low", 1024, "LOW"}, - {"medium", 8192, "MEDIUM"}, - {"high", 32768, "HIGH"}, - {"unknown", 8192, "MEDIUM"}, - } - for _, tt := range tests { - tc := o2gConvertReasoningEffort(tt.effort) - if !tc.IncludeThoughts { - t.Errorf("IncludeThoughts should be true for %q", tt.effort) - } - if tc.ThoughtBudget == nil || *tc.ThoughtBudget != tt.wantBudget { - t.Errorf("effort %q: budget = %v, want %d", tt.effort, tc.ThoughtBudget, tt.wantBudget) - } - if tc.ThinkingLevel != tt.wantLevel { - t.Errorf("effort %q: level = %q, want %q", tt.effort, tc.ThinkingLevel, tt.wantLevel) - } - } -} - -func TestParseGeminiError(t *testing.T) { - tests := []struct { - name string - body string - wantCode int - wantStatus string - wantMessage string - }{ - { - name: "standard RPC error", - body: `{"error":{"code":400,"message":"Invalid argument","status":"INVALID_ARGUMENT"}}`, - wantCode: 400, - wantStatus: "INVALID_ARGUMENT", - wantMessage: "Invalid argument", - }, - { - name: "auth error", - body: `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`, - wantCode: 401, - wantStatus: "UNAUTHENTICATED", - wantMessage: "API key not valid", - }, - { - name: "non-RPC body", - body: `plain text error`, - wantCode: 0, - wantStatus: "", - wantMessage: "plain text error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - code, status, message := parseGeminiError([]byte(tt.body)) - if code != tt.wantCode { - t.Errorf("code = %d, want %d", code, tt.wantCode) - } - if status != tt.wantStatus { - t.Errorf("status = %q, want %q", status, tt.wantStatus) - } - if message != tt.wantMessage { - t.Errorf("message = %q, want %q", message, tt.wantMessage) - } - }) - } -} - -func TestGeminiStatusToOpenAIType(t *testing.T) { - tests := []struct { - status string - expected string - }{ - {"UNAUTHENTICATED", "authentication_error"}, - {"PERMISSION_DENIED", "permission_error"}, - {"INVALID_ARGUMENT", "invalid_request_error"}, - {"NOT_FOUND", "invalid_request_error"}, - {"RESOURCE_EXHAUSTED", "rate_limit_error"}, - {"RATE_LIMIT_EXCEEDED", "rate_limit_error"}, - {"INTERNAL", "internal_error"}, - {"UNAVAILABLE", "server_error"}, - {"DEADLINE_EXCEEDED", "timeout_error"}, - {"UNKNOWN_STATUS", "api_error"}, - } - for _, tt := range tests { - result := geminiStatusToOpenAIType(tt.status) - if result != tt.expected { - t.Errorf("geminiStatusToOpenAIType(%q) = %q, want %q", tt.status, result, tt.expected) - } - } -} - -func TestConvertOpenAIToGemini_Logprobs(t *testing.T) { - info := makeTestInfo() - logprobs := true - topLogprobs := 5 - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - LogProbs: &logprobs, - TopLogProbs: &topLogprobs, - Messages: []dto.Message{ - {Role: "user", Content: "Hello"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - if geminiReq.GenerationConfig.ResponseLogprobs == nil || !*geminiReq.GenerationConfig.ResponseLogprobs { - t.Error("expected ResponseLogprobs to be true") - } - if geminiReq.GenerationConfig.Logprobs == nil || *geminiReq.GenerationConfig.Logprobs != 5 { - t.Errorf("expected Logprobs 5, got %v", geminiReq.GenerationConfig.Logprobs) - } -} - -func TestConvertOpenAIToGemini_ReasoningContentPassthrough(t *testing.T) { - info := makeTestInfo() - thinking := "Let me think about this..." - - openaiReq := dto.GeneralOpenAIRequest{ - Model: "gemini-2.5-flash", - Messages: []dto.Message{ - { - Role: "assistant", - ReasoningContent: &thinking, - }, - {Role: "user", Content: "Continue"}, - }, - } - - body, _ := json.Marshal(openaiReq) - reader, err := ConvertOpenAIToGemini(body, info) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var geminiReq dto.GeminiChatRequest - data, _ := io.ReadAll(reader) - if err := json.Unmarshal(data, &geminiReq); err != nil { - t.Fatalf("failed to unmarshal: %v", err) - } - - // 第一条 content 应为带 thought part 的 model - if len(geminiReq.Contents) < 1 { - t.Fatal("expected at least 1 content") - } - assistantContent := geminiReq.Contents[0] - if assistantContent.Role != "model" { - t.Errorf("expected role 'model', got %s", assistantContent.Role) - } - - hasThought := false - for _, part := range assistantContent.Parts { - if part.Thought != nil && *part.Thought && part.Text == thinking { - hasThought = true - } - } - if !hasThought { - t.Error("expected thought part with reasoning content") - } -} - -func TestGeminiModalityToTokenDetails(t *testing.T) { - tests := []struct { - modality string - count int - field string - expected int - }{ - {"TEXT", 100, "TextTokens", 100}, - {"IMAGE", 50, "ImageTokens", 50}, - {"AUDIO", 30, "AudioTokens", 30}, - {"UNKNOWN", 10, "", 0}, - } - for _, tt := range tests { - td := &common.TokenDetails{} - geminiModalityToTokenDetails(dto.GeminiModalityTokenCount{Modality: tt.modality, TokenCount: tt.count}, td) - var actual int - switch tt.field { - case "TextTokens": - actual = td.TextTokens - case "ImageTokens": - actual = td.ImageTokens - case "AudioTokens": - actual = td.AudioTokens - } - if actual != tt.expected { - t.Errorf("modality %q: got %d, want %d", tt.modality, actual, tt.expected) - } - } -} - -func TestO2gParseStopSequences(t *testing.T) { - tests := []struct { - input any - expected []string - }{ - {nil, nil}, - {"", nil}, - {"stop", []string{"stop"}}, - {[]string{"a", "b"}, []string{"a", "b"}}, - {[]any{"x", "y"}, []string{"x", "y"}}, - {42, nil}, - } - for _, tt := range tests { - result := o2gParseStopSequences(tt.input) - if len(result) != len(tt.expected) { - t.Errorf("o2gParseStopSequences(%v) = %v, want %v", tt.input, result, tt.expected) - continue - } - for i, v := range result { - if v != tt.expected[i] { - t.Errorf("o2gParseStopSequences(%v)[%d] = %q, want %q", tt.input, i, v, tt.expected[i]) - } - } - } -} - -func TestGeminiDTOJsonMarshal(t *testing.T) { - // 验证新增 DTO 字段可正确序列化 - t.Run("GeminiPart with FileData", func(t *testing.T) { - part := dto.GeminiPart{ - FileData: &dto.GeminiFileData{ - MimeType: "video/mp4", - FileURI: "gs://bucket/file.mp4", - }, - } - data, err := json.Marshal(part) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - var parsed dto.GeminiPart - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal error: %v", err) - } - if parsed.FileData == nil || parsed.FileData.FileURI != "gs://bucket/file.mp4" { - t.Errorf("FileData not preserved: %v", parsed.FileData) - } - }) - - t.Run("GeminiFunctionCall with ID", func(t *testing.T) { - fc := dto.GeminiFunctionCall{ - ID: "call_123", - FunctionName: "get_weather", - Arguments: map[string]any{"city": "NYC"}, - } - data, err := json.Marshal(fc) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - var parsed dto.GeminiFunctionCall - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal error: %v", err) - } - if parsed.ID != "call_123" || parsed.FunctionName != "get_weather" { - t.Errorf("FunctionCall fields not preserved: %+v", parsed) - } - }) - - t.Run("GeminiGenerationConfig with new fields", func(t *testing.T) { - gc := dto.GeminiGenerationConfig{ - PresencePenalty: float64Ptr(0.5), - FrequencyPenalty: float64Ptr(0.3), - ResponseLogprobs: boolPtr(true), - Logprobs: intPtr(10), - ResponseModalities: []string{"TEXT", "IMAGE"}, - MediaResolution: "MEDIA_RESOLUTION_HIGH", - } - data, err := json.Marshal(gc) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - var parsed dto.GeminiGenerationConfig - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal error: %v", err) - } - if parsed.PresencePenalty == nil || *parsed.PresencePenalty != 0.5 { - t.Errorf("PresencePenalty not preserved") - } - if len(parsed.ResponseModalities) != 2 || parsed.ResponseModalities[0] != "TEXT" { - t.Errorf("ResponseModalities not preserved: %v", parsed.ResponseModalities) - } - }) - - t.Run("GeminiUsageMetadata with modality details", func(t *testing.T) { - um := dto.GeminiUsageMetadata{ - PromptTokenCount: 100, - CandidatesTokenCount: 200, - TotalTokenCount: 300, - PromptTokensDetails: []dto.GeminiModalityTokenCount{ - {Modality: "TEXT", TokenCount: 80}, - {Modality: "IMAGE", TokenCount: 20}, - }, - } - data, err := json.Marshal(um) - if err != nil { - t.Fatalf("marshal error: %v", err) - } - var parsed dto.GeminiUsageMetadata - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal error: %v", err) - } - if len(parsed.PromptTokensDetails) != 2 { - t.Errorf("PromptTokensDetails not preserved: %v", parsed.PromptTokensDetails) - } - }) -} - -func float64Ptr(v float64) *float64 { return &v } -func boolPtr(v bool) *bool { return &v } -func intPtr(v int) *int { return &v } diff --git a/relay/channel/gemini/response.go b/relay/channel/gemini/response.go index b5a04cd..bd79f8d 100644 --- a/relay/channel/gemini/response.go +++ b/relay/channel/gemini/response.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "strings" - "time" "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" @@ -200,46 +199,25 @@ func (a *Adaptor) handleNonStreamToOpenAI(ctx context.Context, resp *http.Respon return nil, buildGeminiUpstreamError(body, resp.StatusCode) } - // relaykit 响应转换路径(特性开关控制,默认关闭)。失败/未启用回退旧代码路径。 - if convertedBody, _, ok := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body); ok { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(convertedBody) - - // relaykit 转换器返回的 Usage 为 nil(ResponseConverterFunc 签名约束),从原始 Gemini 响应提取 - var geminiResp dto.GeminiChatResponse - if err := json.Unmarshal(body, &geminiResp); err == nil && geminiResp.UsageMetadata != nil { - return geminiUsageToCommon(geminiResp.UsageMetadata), nil - } - // 如果 Usage 解析失败,返回空 Usage(已写响应,不能重试) - return &common.Usage{}, nil + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("gemini adaptor: no relaykit converter for openai client response", nil) } - - // 旧代码路径(relaykit 未启用或失败回退) - var geminiResp dto.GeminiChatResponse - if err := json.Unmarshal(body, &geminiResp); err != nil { - return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + if convErr != nil { + return nil, relaykit_bridge.ResponseConvertError(convErr, resp.StatusCode, resp.Header) } - - // 检查 promptFeedback.blockReason(Gemini 安全过滤) - if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { - return nil, constant.NewRequestError( - fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, - ) - } - - openaiResp := geminiToOpenAIResponse(&geminiResp, info) - - respBody, _ := json.Marshal(openaiResp) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respBody) + _, _ = writer.Write(convertedBody) - usage := &common.Usage{} - if geminiResp.UsageMetadata != nil { - usage = geminiUsageToCommon(geminiResp.UsageMetadata) + // relaykit 转换器返回的 Usage 为 nil(ResponseConverterFunc 签名约束),从原始 Gemini 响应提取 + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err == nil && geminiResp.UsageMetadata != nil { + return geminiUsageToCommon(geminiResp.UsageMetadata), nil } - return usage, nil + // 如果 Usage 解析失败,返回空 Usage(已写响应,不能重试) + return &common.Usage{}, nil } // handleStreamToOpenAI 将 Gemini 流式响应转换为 OpenAI SSE 格式 @@ -252,315 +230,14 @@ func (a *Adaptor) handleStreamToOpenAI(ctx context.Context, resp *http.Response, return nil, buildGeminiUpstreamError(body, resp.StatusCode) } - // relaykit 流式转换(常开)。同格式/无匹配转换器时回退旧路径。 - if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { - // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算 - if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { - return usage, common.ErrStreamInterrupted - } - return usage, nil + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - isCA := a.isCodeAssistActive() - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - var ( - totalUsage dto.GeminiUsageMetadata - finishReason string - toolCallIdx int - modelName string - ) - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断:返回已累计的 usage(Gemini 每个 chunk 携带累计值),输入缺失用请求侧估算补齐 - interruptedUsage := geminiUsageToCommon(&totalUsage) - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, 0) - return interruptedUsage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" { - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - - data, _ := helper.ExtractSSEData(line) - - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } - - if data == "[DONE]" { - break - } - - var geminiResp dto.GeminiChatResponse - rawData := []byte(data) - if isCA { - rawData = unwrapCodeAssistData(rawData) - } - if err := json.Unmarshal(rawData, &geminiResp); err != nil { - continue - } - - // 收集用量 - if geminiResp.UsageMetadata != nil { - totalUsage = *geminiResp.UsageMetadata - } - - // 检查 promptFeedback.blockReason(流式安全过滤) - if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { - // SSE 头已发送,需先发送结束 chunk 和 [DONE] 避免客户端挂起 - filterReason := "content_filter" - endChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: info.OriginModelName, - Choices: []dto.StreamChoice{{ - Index: 0, - FinishReason: &filterReason, - }}, - } - writeStreamChunk(writer, &endChunk) - _ = helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - return nil, constant.NewRequestError( - fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, - ) - } - - // 收集模型名 - if geminiResp.ModelName != "" { - modelName = geminiResp.ModelName - } - - for _, candidate := range geminiResp.Candidates { - if candidate.FinishReason != "" { - finishReason = common.GeminiFinishReasonToOpenAI(candidate.FinishReason) - } - - if candidate.Content == nil { - continue - } - - for _, part := range candidate.Content.Parts { - isThought := part.Thought != nil && *part.Thought - - // 文本内容 - if part.Text != "" { - if isThought { - // 思考内容 → reasoning_content - text := part.Text - chunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - ReasoningContent: &text, - }, - }}, - } - if modelName == "" { - chunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &chunk) - } else { - chunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - Content: part.Text, - }, - }}, - } - if modelName == "" { - chunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &chunk) - } - } - - // 内联图片数据 - if part.InlineData != nil { - imageText := fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data) - imageChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - Content: imageText, - }, - }}, - } - if modelName == "" { - imageChunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &imageChunk) - } - - // 可执行代码 - if part.ExecutableCode != nil { - codeText := fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) - codeChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - Content: codeText, - }, - }}, - } - if modelName == "" { - codeChunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &codeChunk) - } - - // 代码执行结果 - if part.CodeExecutionResult != nil { - resultText := fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) - resultChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - Content: resultText, - }, - }}, - } - if modelName == "" { - resultChunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &resultChunk) - } - - // 文件数据 - if part.FileData != nil { - fileText := fmt.Sprintf("[file](%s)", part.FileData.FileURI) - fileChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - Role: "assistant", - Content: fileText, - }, - }}, - } - if modelName == "" { - fileChunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &fileChunk) - } - - // 函数调用 - if part.FunctionCall != nil { - argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) - chunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: dto.Message{ - ToolCalls: []dto.ToolCall{{ - ID: fmt.Sprintf("call_%s_%d", info.RequestID, toolCallIdx), - Type: "function", - Function: dto.FunctionCall{ - Name: part.FunctionCall.FunctionName, - Arguments: string(argsJSON), - }, - }}, - }, - }}, - } - if modelName == "" { - chunk.Model = info.OriginModelName - } - writeStreamChunk(writer, &chunk) - toolCallIdx++ - } - } - } - } - - // 发送结束 chunk - reason := finishReason - if reason == "" { - reason = "stop" - } - endChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: modelName, - Choices: []dto.StreamChoice{{ - Index: 0, - FinishReason: &reason, - }}, - } - if modelName == "" { - endChunk.Model = info.OriginModelName - } - - if totalUsage.PromptTokenCount > 0 || totalUsage.CandidatesTokenCount > 0 { - // OpenAI 口径:prompt 含 cached(子集),completion 含 thoughts(子集), - // 与计费用量 geminiUsageToCommon 保持同一换算 - endChunk.Usage = &dto.UsageWithDetails{ - PromptTokens: totalUsage.PromptTokenCount, - CompletionTokens: totalUsage.CandidatesTokenCount + totalUsage.ThoughtsTokenCount, - TotalTokens: totalUsage.TotalTokenCount, - PromptTokensDetails: &dto.TokenDetails{ - CachedTokens: totalUsage.CachedContentTokenCount, - }, - CompletionTokenDetails: &dto.TokenDetails{ - ReasoningTokens: totalUsage.ThoughtsTokenCount, - }, - } - } - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - writeStreamChunk(writer, &endChunk) - _ = helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return geminiUsageToCommon(&totalUsage), fmt.Errorf("stream scanner error: %w", err) - } - - writeStreamChunk(writer, &endChunk) - _ = helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - - return geminiUsageToCommon(&totalUsage), nil + return nil, constant.NewChannelError("gemini adaptor: relaykit stream converter unavailable for openai client", nil) } // geminiToOpenAIResponse 将 Gemini 非流式响应转换为 OpenAI ChatCompletion 格式 diff --git a/relay/channel/gemini/responses_bridge.go b/relay/channel/gemini/responses_bridge.go new file mode 100644 index 0000000..6038e82 --- /dev/null +++ b/relay/channel/gemini/responses_bridge.go @@ -0,0 +1,196 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/qianfree/team-api/relay/channel/openai" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/relaykit_bridge" +) + +// ========== Responses 入站桥接:Gemini → OpenAI Responses ========== +// +// 请求侧由 relaykit 的步骤链完成(Responses → OpenAI 中枢 → Gemini), +// 响应侧同样由 relaykit 转换器完成(Gemini → Responses,含 SSE 事件序列产出)。 +// 本文件只保留宿主侧接线:HTTP 状态码/安全过滤错误处理、响应写出与计费用量提取, +// 以及 Code Assist 强制流式聚合路径复用的非流式装配函数(buildResponsesBodyFromGemini +// 及其助手,由 adaptor.go 的 handleCodeAssistAggregatedStream 调用)。 +// +// 用量口径:Responses 的客户端可见 usage 与本渠道的计费口径**恰好一致** +// (都是 OpenAI 语义:input 含缓存、output 含思考),故计费值直接取 geminiUsageToCommon, +// 不像 claude→responses 那样需要两套换算。 + +// handleNonStreamToResponses 将 Gemini 非流式响应转换为 Responses 格式 +func (a *Adaptor) handleNonStreamToResponses(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err) + } + if resp.StatusCode != http.StatusOK { + // 不写响应:交上层 WriteRelayError 统一写入,避免双重写入与重试时的响应污染 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) + } + + // 安全过滤(promptFeedback.blockReason)属上游错误语义,先于协议转换判定 + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + } + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, constant.NewRequestError( + fmt.Sprintf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason), nil, + ) + } + + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误,不再回退旧实现) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("gemini adaptor: no relaykit converter for responses client response", nil) + } + if convErr != nil { + return nil, relaykit_bridge.ResponseConvertError(convErr, resp.StatusCode, resp.Header) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(convertedBody) + + // 计费用量取 Gemini 原生口径(与客户端可见口径一致) + return geminiUsageToCommon(geminiResp.UsageMetadata), nil +} + +// buildResponsesBodyFromGemini 构建 Responses 非流式响应体与用量。 +// 直连非流式与 Code Assist 强制流式的聚合结果共用同一套装配逻辑。 +func buildResponsesBodyFromGemini(geminiResp *dto.GeminiChatResponse, info *common.RelayInfo) ([]byte, *common.Usage, error) { + output := buildResponsesOutputFromGemini(geminiResp, info) + + modelName := geminiResp.ModelName + if modelName == "" || info.ChannelMeta.IsModelMapped { + modelName = info.OriginModelName + } + createdAt := int(time.Now().Unix()) + completedAt := createdAt + + usage := geminiUsageToCommon(geminiResp.UsageMetadata) + + body, err := json.Marshal(openai.BuildResponsesObjectMap( + responsesIDFromGemini(geminiResp, info), createdAt, "completed", modelName, + output, openai.BuildResponsesUsageMap(usage), &completedAt, info, + )) + if err != nil { + return nil, nil, fmt.Errorf("marshal responses body failed: %w", err) + } + return body, usage, nil +} + +// handleStreamToResponses 将 Gemini 流式响应转换为 Responses 格式的 SSE +func (a *Adaptor) handleStreamToResponses(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + // 不写响应:交上层 WriteRelayError 统一写入 + return nil, buildGeminiUpstreamError(body, resp.StatusCode) + } + + // relaykit 流式转换(唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err + } + return nil, constant.NewChannelError("gemini adaptor: relaykit stream converter unavailable for responses client", nil) +} + +// buildResponsesOutputFromGemini 构建 Responses 非流式响应的 output 数组。 +// 文本合并为单个 message 项(居首),functionCall 各成一个 function_call 项。 +func buildResponsesOutputFromGemini(geminiResp *dto.GeminiChatResponse, info *common.RelayInfo) []map[string]any { + var textParts []string + output := make([]map[string]any, 0) + toolIdx := 0 + + for _, candidate := range geminiResp.Candidates { + if candidate.Content == nil { + continue + } + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" && !isThought { + textParts = append(textParts, part.Text) + } + // 思考内容无 Responses 非流式对应物,跳过(与 claude→responses 口径一致) + + if part.FunctionCall != nil { + id := geminiResponsesCallID(part.FunctionCall, info, toolIdx) + toolIdx++ + output = append(output, map[string]any{ + "type": "function_call", + "id": id, + "call_id": id, + "name": part.FunctionCall.FunctionName, + "arguments": marshalGeminiArgs(part.FunctionCall), + "status": "completed", + }) + } + + if fallback := geminiPartFallbackText(part); fallback != "" { + textParts = append(textParts, fallback) + } + } + } + + if len(textParts) > 0 { + msgItem := map[string]any{ + "type": "message", + "id": fmt.Sprintf("msg_%s", info.RequestID), + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": strings.Join(textParts, ""), + "annotations": []any{}, + }}, + } + output = append([]map[string]any{msgItem}, output...) + } + + return output +} + +// responsesIDFromGemini 生成 Responses 响应 ID,优先用上游 responseId +func responsesIDFromGemini(geminiResp *dto.GeminiChatResponse, info *common.RelayInfo) string { + if geminiResp.ResponseID != "" { + return fmt.Sprintf("resp_%s", geminiResp.ResponseID) + } + return fmt.Sprintf("resp_%s", info.RequestID) +} + +// geminiResponsesCallID 取工具调用 ID:Gemini 的 functionCall.id 可选,缺失时合成。 +// Responses 的 function_call 必须带 call_id,客户端下一轮按此 ID 回传结果。 +func geminiResponsesCallID(fc *dto.GeminiFunctionCall, info *common.RelayInfo, idx int) string { + if fc != nil && fc.ID != "" { + return fc.ID + } + return fmt.Sprintf("call_%s_%d", info.RequestID, idx) +} + +// marshalGeminiArgs 将 functionCall 参数序列化为 Responses 期望的 JSON 字符串 +func marshalGeminiArgs(fc *dto.GeminiFunctionCall) string { + b, err := json.Marshal(geminiFunctionArgs(fc)) + if err != nil { + return "{}" + } + return string(b) +} diff --git a/relay/channel/gemini/responses_bridge_test.go b/relay/channel/gemini/responses_bridge_test.go new file mode 100644 index 0000000..53820d0 --- /dev/null +++ b/relay/channel/gemini/responses_bridge_test.go @@ -0,0 +1,391 @@ +package gemini + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// responsesInboundAdaptor 构造 Responses 入站 + Gemini 上游的已 Init 适配器 +func responsesInboundAdaptor(stream bool) (*Adaptor, *common.RelayInfo) { + info := &common.RelayInfo{ + RelayMode: int(constant.RelayModeResponses), + IsStream: stream, + RequestID: "req123", + InboundFormat: constant.RelayFormatResponses, + ClientFormat: constant.RelayFormatResponses, + OriginModelName: "gemini-3-pro", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderGemini), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gemini-3-pro", + }, + } + a := &Adaptor{} + a.Init(info) + return a, info +} + +// parseResponsesSSE 解析 Responses 格式 SSE(event + data 行)为有序事件列表。 +// 复用 claude_bridge_test.go 的 sseEvent / parseClaudeSSE —— 两者帧格式相同。 +func parseResponsesSSE(t *testing.T, raw string) []sseEvent { + t.Helper() + return parseClaudeSSE(t, raw) +} + +// ===== 非流式 ===== + +// TestBuildResponsesOutputFromGemini_TextAndTool 文本合并为居首的 message 项, +// functionCall 各成一个 function_call 项;思考内容在非流式响应中跳过。 +func TestBuildResponsesOutputFromGemini_TextAndTool(t *testing.T) { + thought := true + _, info := responsesInboundAdaptor(false) + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "思考中", Thought: &thought}, + {Text: "你好"}, + {Text: "世界"}, + {FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: "get_weather", + Arguments: map[string]any{"city": "北京"}, + }}, + }, + }, + FinishReason: "STOP", + }}, + } + + output := buildResponsesOutputFromGemini(geminiResp, info) + + if len(output) != 2 { + t.Fatalf("output 项数 = %d, want 2(message + function_call): %+v", len(output), output) + } + + msg := output[0] + if msg["type"] != "message" || msg["status"] != "completed" || msg["role"] != "assistant" { + t.Errorf("首项应为 completed 的 assistant message: %+v", msg) + } + content := msg["content"].([]map[string]any) + if len(content) != 1 || content[0]["type"] != "output_text" { + t.Fatalf("message content 结构不对: %+v", content) + } + // 思考内容不得混进正文 + if got := content[0]["text"]; got != "你好世界" { + t.Errorf("正文 = %q, want %q(思考内容应被跳过)", got, "你好世界") + } + + fc := output[1] + if fc["type"] != "function_call" || fc["name"] != "get_weather" { + t.Errorf("次项应为 function_call: %+v", fc) + } + if fc["id"] != fc["call_id"] { + t.Errorf("id 与 call_id 应一致: %+v", fc) + } + var args map[string]any + if err := json.Unmarshal([]byte(fc["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments 应为 JSON 字符串: %v", err) + } + if args["city"] != "北京" { + t.Errorf("参数丢失: %+v", args) + } +} + +// TestGeminiResponsesCallID_PrefersUpstreamID Gemini 的 functionCall.id 可选, +// 有则直接用,无则合成(Responses 的 function_call 必须带 call_id)。 +func TestGeminiResponsesCallID_PrefersUpstreamID(t *testing.T) { + _, info := responsesInboundAdaptor(false) + + withID := geminiResponsesCallID(&dto.GeminiFunctionCall{ID: "up_1", FunctionName: "f"}, info, 0) + if withID != "up_1" { + t.Errorf("有上游 id 时应直接使用, got %q", withID) + } + + a := geminiResponsesCallID(&dto.GeminiFunctionCall{FunctionName: "f"}, info, 0) + b := geminiResponsesCallID(&dto.GeminiFunctionCall{FunctionName: "f"}, info, 1) + if a == "" || a == b { + t.Errorf("合成 ID 必须非空且互不相同: %q / %q", a, b) + } + if !strings.HasPrefix(a, "call_") { + t.Errorf("合成 ID 应为 call_ 前缀: %q", a) + } +} + +// TestHandleNonStreamToResponses_WritesResponsesBody 正常路径:响应体为 Responses 对象, +// 用量按 OpenAI 语义(input 含缓存、output 含思考、reasoning_tokens 单列)。 +func TestHandleNonStreamToResponses_WritesResponsesBody(t *testing.T) { + body := `{"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]},"finishReason":"STOP"}],` + + `"modelVersion":"gemini-3-pro","responseId":"gr_1",` + + `"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}` + + a, info := responsesInboundAdaptor(false) + rec := httptest.NewRecorder() + + usage, err := a.handleNonStreamToResponses(context.Background(), jsonResponse(http.StatusOK, body), info, rec) + if err != nil { + t.Fatalf("handleNonStreamToResponses error: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("响应体不是合法 JSON: %v\nbody=%s", err, rec.Body.String()) + } + if got["object"] != "response" || got["status"] != "completed" { + t.Errorf("Responses 骨架不对: object=%v status=%v", got["object"], got["status"]) + } + if id, _ := got["id"].(string); id != "resp_gr_1" { + t.Errorf("id = %q, want resp_gr_1(应优先用上游 responseId)", id) + } + if got["completed_at"] == nil { + t.Error("completed 状态必须带 completed_at") + } + + u := got["usage"].(map[string]any) + if u["input_tokens"].(float64) != 50 { + t.Errorf("input_tokens = %v, want 50(OpenAI 语义含缓存)", u["input_tokens"]) + } + if u["output_tokens"].(float64) != 11 { + t.Errorf("output_tokens = %v, want 11(candidates+thoughts)", u["output_tokens"]) + } + if rt := u["output_tokens_details"].(map[string]any)["reasoning_tokens"].(float64); rt != 3 { + t.Errorf("reasoning_tokens = %v, want 3", rt) + } + if ct := u["input_tokens_details"].(map[string]any)["cached_tokens"].(float64); ct != 10 { + t.Errorf("cached_tokens = %v, want 10", ct) + } + + // 计费口径与客户端可见口径在本方向一致 + if usage.PromptTokens != 50 || usage.CompletionTokens != 11 || !usage.CacheIncludedInPrompt { + t.Errorf("计费用量不对: %+v", usage) + } +} + +// TestHandleNonStreamToResponses_UpstreamErrorNotWritten 上游错误不在本层写响应 +func TestHandleNonStreamToResponses_UpstreamErrorNotWritten(t *testing.T) { + a, info := responsesInboundAdaptor(false) + rec := httptest.NewRecorder() + body := `{"error":{"code":429,"status":"RESOURCE_EXHAUSTED","message":"quota"}}` + + _, err := a.handleNonStreamToResponses(context.Background(), jsonResponse(http.StatusTooManyRequests, body), info, rec) + + if err == nil { + t.Fatal("上游 429 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} + +// TestHandleNonStreamToResponses_SafetyBlock 安全过滤命中时返回请求类错误且不写响应体 +func TestHandleNonStreamToResponses_SafetyBlock(t *testing.T) { + a, info := responsesInboundAdaptor(false) + rec := httptest.NewRecorder() + + _, err := a.handleNonStreamToResponses(context.Background(), + jsonResponse(http.StatusOK, `{"promptFeedback":{"blockReason":"SAFETY"}}`), info, rec) + + if err == nil { + t.Fatal("安全过滤应返回错误") + } + assertContentBlockedNotUpstream(t, err) + if rec.Body.Len() != 0 { + t.Errorf("不应写响应体, got %q", rec.Body.String()) + } +} + +// ===== 流式 ===== + +// TestHandleStreamToResponses_EventSequence 完整事件序列: +// created → output_item.added → content_part.added → 文本/思考增量 → 三个 done → completed +func TestHandleStreamToResponses_EventSequence(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"想一下","thought":true}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"世界"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}`, + ``, + ``, + }, "\n") + + a, info := responsesInboundAdaptor(true) + rec := httptest.NewRecorder() + + usage, err := a.handleStreamToResponses(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleStreamToResponses error: %v", err) + } + + events := parseResponsesSSE(t, rec.Body.String()) + got := eventTypes(events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.reasoning_summary_text.delta", + "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // 思考增量走 reasoning summary,不得混进正文 + if events[3].Data["delta"] != "想一下" { + t.Errorf("reasoning delta 不对: %+v", events[3].Data) + } + if events[6].Data["text"] != "你好世界" { + t.Errorf("output_text.done 文本 = %v, want 你好世界", events[6].Data["text"]) + } + + completed := events[9].Data["response"].(map[string]any) + if completed["status"] != "completed" { + t.Errorf("最终状态 = %v", completed["status"]) + } + out := completed["output"].([]any) + if len(out) != 1 || out[0].(map[string]any)["type"] != "message" { + t.Fatalf("completed.output 应含单个 message 项: %+v", out) + } + u := completed["usage"].(map[string]any) + if u["input_tokens"].(float64) != 50 || u["output_tokens"].(float64) != 11 { + t.Errorf("completed usage 不对: %+v", u) + } + + if usage.PromptTokens != 50 || usage.CompletionTokens != 11 { + t.Errorf("计费用量 = %d/%d, want 50/11", usage.PromptTokens, usage.CompletionTokens) + } +} + +// TestHandleStreamToResponses_ToolCall 工具调用前必须先关闭文本 content part, +// 且 function_call 项的 output_index 与文本项不冲突。 +func TestHandleStreamToResponses_ToolCall(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"查一下"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"北京"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}`, + ``, + ``, + }, "\n") + + a, info := responsesInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToResponses(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleStreamToResponses error: %v", err) + } + + events := parseResponsesSSE(t, rec.Body.String()) + got := eventTypes(events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + // 工具调用前先收掉文本 part + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // 文本项 output_index=0,工具项 output_index=1 + if idx := events[6].Data["output_index"].(float64); idx != 0 { + t.Errorf("文本项 output_index = %v, want 0", idx) + } + if idx := events[7].Data["output_index"].(float64); idx != 1 { + t.Errorf("工具项 output_index = %v, want 1", idx) + } + + toolItem := events[7].Data["item"].(map[string]any) + if toolItem["type"] != "function_call" || toolItem["name"] != "get_weather" { + t.Errorf("function_call 项不对: %+v", toolItem) + } + + completed := events[11].Data["response"].(map[string]any) + out := completed["output"].([]any) + if len(out) != 2 { + t.Fatalf("completed.output 应含 message + function_call: %+v", out) + } + if out[0].(map[string]any)["type"] != "message" || out[1].(map[string]any)["type"] != "function_call" { + t.Errorf("output 顺序不对: %+v", out) + } +} + +// TestHandleStreamToResponses_EmptyUpstreamStillCompletes 上游无有效 chunk 时仍须合成 +// created/completed,否则 Responses 客户端一直等 response.completed。 +func TestHandleStreamToResponses_EmptyUpstreamStillCompletes(t *testing.T) { + a, info := responsesInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToResponses(context.Background(), sseResponse(strings.NewReader("\n\n")), info, rec) + if err != nil { + t.Fatalf("handleStreamToResponses error: %v", err) + } + + got := eventTypes(parseResponsesSSE(t, rec.Body.String())) + if len(got) == 0 || got[0] != "response.created" || got[len(got)-1] != "response.completed" { + t.Fatalf("必须以 created 开场、completed 收尾, got %v", got) + } +} + +// TestHandleStreamToResponses_SafetyBlockCompletes 安全过滤命中时 SSE 头已发出, +// 必须补齐 completed(否则客户端挂起)并向上返回请求类错误(同 handleStreamToClaude 口径)。 +func TestHandleStreamToResponses_SafetyBlockCompletes(t *testing.T) { + a, info := responsesInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToResponses(context.Background(), + sseResponse(strings.NewReader("data: {\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n")), info, rec) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + assertContentBlockedError(t, err) + if got := info.StreamStatus.GetEndReason(); got != common.StreamEndReasonError { + t.Errorf("StreamStatus end reason = %v, want %v", got, common.StreamEndReasonError) + } + + got := eventTypes(parseResponsesSSE(t, rec.Body.String())) + if len(got) == 0 || got[len(got)-1] != "response.completed" { + t.Errorf("必须以 response.completed 收尾, got %v", got) + } +} + +// TestHandleStreamToResponses_UpstreamErrorNotWritten 流式上游错误不在本层写响应 +func TestHandleStreamToResponses_UpstreamErrorNotWritten(t *testing.T) { + a, info := responsesInboundAdaptor(true) + rec := httptest.NewRecorder() + resp := jsonResponse(http.StatusServiceUnavailable, `{"error":{"code":503,"status":"UNAVAILABLE","message":"down"}}`) + + _, err := a.handleStreamToResponses(context.Background(), resp, info, rec) + + if err == nil { + t.Fatal("上游 503 应返回错误") + } + if rec.Body.Len() != 0 { + t.Errorf("本层不应写响应体, got %q", rec.Body.String()) + } +} diff --git a/relay/channel/gemini/safety_block_openai_test.go b/relay/channel/gemini/safety_block_openai_test.go new file mode 100644 index 0000000..fcfb15b --- /dev/null +++ b/relay/channel/gemini/safety_block_openai_test.go @@ -0,0 +1,74 @@ +package gemini + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// openaiInboundAdaptor 构造 OpenAI 入站 + Gemini 上游的已 Init 适配器。 +// 三个 Gemini 出站方向(→Claude / →Responses / →OpenAI)共用同一套安全拦截口径, +// 前两者已在各自 bridge_test 覆盖,本文件补齐 →OpenAI 这一路。 +func openaiInboundAdaptor(stream bool) (*Adaptor, *common.RelayInfo) { + info := &common.RelayInfo{ + RelayMode: int(constant.RelayModeChatCompletions), + IsStream: stream, + RequestID: "req123", + InboundFormat: constant.RelayFormatOpenAI, + ClientFormat: constant.RelayFormatOpenAI, + OriginModelName: "gemini-3-pro", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderGemini), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gemini-3-pro", + }, + } + a := &Adaptor{} + a.Init(info) + return a, info +} + +// TestHandleNonStreamToOpenAI_SafetyBlock 非流式安全拦截:返回请求类错误(4xx)且不写响应体。 +func TestHandleNonStreamToOpenAI_SafetyBlock(t *testing.T) { + a, info := openaiInboundAdaptor(false) + rec := httptest.NewRecorder() + + _, err := a.handleNonStreamToOpenAI(context.Background(), + jsonResponse(http.StatusOK, `{"promptFeedback":{"blockReason":"SAFETY"}}`), info, rec) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + assertContentBlockedNotUpstream(t, err) + if rec.Body.Len() != 0 { + t.Errorf("不应写响应体, got %q", rec.Body.String()) + } +} + +// TestHandleStreamToOpenAI_SafetyBlock 流式安全拦截:SSE 头已提交,必须写 [DONE] 收尾 +// 并返回带 ResponseWritten 的请求类错误(不得按上游 5xx 罚渠道健康、不得换渠道重试)。 +func TestHandleStreamToOpenAI_SafetyBlock(t *testing.T) { + a, info := openaiInboundAdaptor(true) + rec := httptest.NewRecorder() + + _, err := a.handleStreamToOpenAI(context.Background(), + sseResponse(strings.NewReader("data: {\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n")), info, rec) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + assertContentBlockedError(t, err) + if got := info.StreamStatus.GetEndReason(); got != common.StreamEndReasonError { + t.Errorf("StreamStatus end reason = %v, want %v", got, common.StreamEndReasonError) + } + // OpenAI 客户端方向的终止语义靠 [DONE],缺失会让客户端一直挂着等下一帧 + if !strings.Contains(rec.Body.String(), "[DONE]") { + t.Errorf("必须以 [DONE] 收尾, got %q", rec.Body.String()) + } +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go index 413166c..99d1420 100644 --- a/relay/channel/jimeng/adaptor.go +++ b/relay/channel/jimeng/adaptor.go @@ -64,15 +64,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // 仅支持图像生成模式,对非图像模式返回错误。 // 请求体直通,仅做模型名映射。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if constant.RelayMode(info.RelayMode) != constant.RelayModeImagesGenerations { return nil, fmt.Errorf("Jimeng only supports image generation mode") } diff --git a/relay/channel/minimax/adaptor.go b/relay/channel/minimax/adaptor.go index 3a54242..5d7925a 100644 --- a/relay/channel/minimax/adaptor.go +++ b/relay/channel/minimax/adaptor.go @@ -33,7 +33,9 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { switch constant.RelayMode(info.RelayMode) { case constant.RelayModeClaudeMessages: return baseURL + "/anthropic/v1/messages", nil - case constant.RelayModeChatCompletions: + // Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return baseURL + "/v1/text/chatcompletion_v2", nil case constant.RelayModeImagesGenerations: return baseURL + "/v1/image/generation", nil @@ -62,15 +64,6 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertClaudeRequest(requestBody, info) } - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go index 49d6163..1225ae1 100644 --- a/relay/channel/mistral/adaptor.go +++ b/relay/channel/mistral/adaptor.go @@ -51,15 +51,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。Mistral 兼容 OpenAI 格式,只需做模型名映射。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go index 2d6fa22..c7d9003 100644 --- a/relay/channel/moonshot/adaptor.go +++ b/relay/channel/moonshot/adaptor.go @@ -59,15 +59,6 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertClaudeRequest(requestBody, info) } - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go index 7e7919e..04c6252 100644 --- a/relay/channel/ollama/adaptor.go +++ b/relay/channel/ollama/adaptor.go @@ -167,66 +167,21 @@ func (a *Adaptor) handleChatNonStreamResponse(ctx context.Context, resp *http.Re return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } - // relaykit 响应转换路径(仅 chat;generate/embedding 不迁移,走旧路径) - if convertedBody, _, ok := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body); ok { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(convertedBody) - if usage, ok := relaykit_bridge.UsageFromConvertedChatResponse(convertedBody); ok { - return usage, nil - } - return &common.Usage{}, nil + // relaykit 响应转换(chat 唯一路径,hard-fail:解析/转换失败即向上返回错误) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("ollama adaptor: no relaykit converter for chat response", nil) } - - var ollamaResp OllamaChatResponse - if err := json.Unmarshal(body, &ollamaResp); err != nil { - return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", err) + if convErr != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", convErr) } - - // 转换为 OpenAI ChatCompletion 格式 - message := dto.Message{ - Role: ollamaResp.Message.Role, - Content: ollamaResp.Message.Content, - } - if reasoning := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); reasoning != nil { - message.ReasoningContent = reasoning - } - if toolCalls := legacyOllamaToolCallsToOpenAI(ollamaResp.Message.ToolCalls); len(toolCalls) > 0 { - message.ToolCalls = toolCalls - } - finishReason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) - if len(message.ToolCalls) > 0 { - finishReason = "tool_calls" - } - openaiResp := dto.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion", - Created: time.Now().Unix(), - Model: info.OriginModelName, - Choices: []dto.Choice{ - { - Index: 0, - Message: message, - FinishReason: finishReason, - }, - }, - Usage: dto.UsageWithDetails{ - PromptTokens: ollamaResp.PromptEvalCount, - CompletionTokens: ollamaResp.EvalCount, - TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, - }, - } - - respBody, _ := json.Marshal(openaiResp) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respBody) - - return &common.Usage{ - PromptTokens: ollamaResp.PromptEvalCount, - CompletionTokens: ollamaResp.EvalCount, - TotalTokens: ollamaResp.PromptEvalCount + ollamaResp.EvalCount, - }, nil + _, _ = writer.Write(convertedBody) + if usage, ok := relaykit_bridge.UsageFromConvertedChatResponse(convertedBody); ok { + return usage, nil + } + return &common.Usage{}, nil } // handleChatStreamResponse 处理 Ollama Chat 流式响应(NDJSON → SSE 转换) @@ -238,119 +193,14 @@ func (a *Adaptor) handleChatStreamResponse(ctx context.Context, resp *http.Respo return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil) } - // relaykit 流式转换(仅 chat;generate/embedding 不迁移,走旧路径) - if usage, ok := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { - // 流中断(客户端断开/写失败):桥接层已完成 usage 兜底,透传中断信号供上层按中断结算 - if info.StreamStatus != nil && info.StreamStatus.IsPartialStreamEnd() { - return usage, common.ErrStreamInterrupted - } - return usage, nil + // relaykit 流式转换(chat 唯一路径,hard-fail):写入前失败由桥接层返回未接管,此处显式报错 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - var usage common.Usage - var transferredTextLen int // 已转发的文本长度,供流中断输出估算 - - sawToolCalls := false - nextToolCallIndex := 0 - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen) - return &usage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" { - continue - } - - var ollamaResp OllamaChatResponse - if err := json.Unmarshal([]byte(line), &ollamaResp); err != nil { - continue - } - - info.SetFirstResponseTime() - - if ollamaResp.Done { - // 最后一条消息包含用量信息 - usage.PromptTokens = ollamaResp.PromptEvalCount - usage.CompletionTokens = ollamaResp.EvalCount - usage.TotalTokens = ollamaResp.PromptEvalCount + ollamaResp.EvalCount - - // 发送带 finish_reason 的结束 chunk - finishReason := ollamaDoneReasonToFinishReason(ollamaResp.DoneReason) - if sawToolCalls { - finishReason = "tool_calls" - } - endChunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: info.OriginModelName, - Choices: []dto.StreamChoice{ - { - Index: 0, - Delta: dto.Message{}, - FinishReason: &finishReason, - }, - }, - Usage: &dto.UsageWithDetails{ - PromptTokens: usage.PromptTokens, - CompletionTokens: usage.CompletionTokens, - TotalTokens: usage.TotalTokens, - }, - } - writeStreamChunk(writer, &endChunk) - break - } - - // 构建 OpenAI 流式 chunk - transferredTextLen += len(ollamaResp.Message.Content) - delta := dto.Message{ - Role: ollamaResp.Message.Role, - Content: ollamaResp.Message.Content, - } - if thinking := ollamaThinkingToOpenAI(ollamaResp.Message.Thinking); thinking != nil { - delta.ReasoningContent = thinking - } - if toolCalls := legacyOllamaStreamToolCalls(ollamaResp.Message.ToolCalls, &nextToolCallIndex); len(toolCalls) > 0 { - delta.ToolCalls = toolCalls - sawToolCalls = true - } - chunk := dto.ChatCompletionStreamResponse{ - ID: fmt.Sprintf("chatcmpl-%s", info.RequestID), - Object: "chat.completion.chunk", - Model: info.OriginModelName, - Choices: []dto.StreamChoice{ - { - Index: 0, - Delta: delta, - }, - }, - } - writeStreamChunk(writer, &chunk) - } - - // 发送 [DONE] - _ = helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return &usage, fmt.Errorf("stream scanner error: %w", err) - } - - return &usage, nil + return nil, constant.NewChannelError("ollama adaptor: relaykit stream converter unavailable for chat", nil) } // handleGenerateNonStreamResponse 处理 Ollama Generate 非流式响应,转换为 OpenAI Completions 格式 diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 6f2c3b8..bee7f4a 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -34,7 +34,7 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages: + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, constant.RelayModeGeminiChat: if info.UseResponsesAPI { return baseURL + "/v1/responses", nil } @@ -144,52 +144,19 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return bytes.NewReader(requestBody), nil } - var converted io.Reader + // 以下方向已由 relaykit 转换矩阵接管(convertRequestBody 在 adaptor 之前完成): + // - Claude/Gemini 入站 → chat 转换; + // - Responses 入站 + chat-only 上游(Responses→chat); + // - chat 入站 + UseResponsesAPI(chat→Responses 桥接,含 reasoning_effort 前置注入)。 + // 此处只剩同格式路径:OpenAI 入站(含空)、Responses 入站 + Responses 原生上游。 + // 矩阵意外未接管时显式报错,避免外格式体静默透传。 switch info.InboundFormat { - case constant.RelayFormatClaude: - r, err := ConvertClaudeToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatGemini: - r, err := ConvertGeminiToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - converted = r - case constant.RelayFormatResponses: - if responsesUpstream { - // 上游为 Responses 协议:保持 Responses 格式直连,仅做模型映射等后处理 - converted = bytes.NewReader(requestBody) - } else { - r, err := ConvertResponsesToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - converted = r - } + case constant.RelayFormatOpenAI, "", constant.RelayFormatResponses: default: - converted = bytes.NewReader(requestBody) + return nil, constant.NewChannelError(fmt.Sprintf("openai adaptor: relaykit converter unavailable for %s inbound", info.InboundFormat), nil) } - // responses-only 上游桥接:chat 请求体转换为 Responses 格式发送 /v1/responses。 - // 转换器自行处理模型映射,并读取 chat 体的 reasoning_effort 映射为 reasoning.effort - // (thinking 后缀先注入 chat 体再进转换器),故此分支直接返回、不走后续 chat 后处理。 - if info.UseResponsesAPI && mode == constant.RelayModeChatCompletions && !responsesUpstream { - if info.ReasoningEffort != "" { - converted = injectReasoningEffort(converted, info.ReasoningEffort) - } - body, err := io.ReadAll(converted) - if err != nil { - return nil, fmt.Errorf("read chat body for responses bridge failed: %w", err) - } - r, err := ConvertOpenAIToResponses(body, info) - if err != nil { - return nil, err - } - return bytes.NewReader(r), nil - } + converted := bytes.NewReader(requestBody) result := replaceModelIfNeeded(converted, info) // stream_options 是 Chat Completions 专属字段,GPT Image 使用 stream/partial_images 原生参数; @@ -198,18 +165,48 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re result = InjectStreamOptions(result, info) } - // Thinking 后缀路由:Responses 上游映射为 reasoning.effort,其余注入 chat 的 reasoning_effort - if info.ReasoningEffort != "" { - if responsesUpstream { - result = injectResponsesReasoning(result, info.ReasoningEffort) - } else { - result = injectReasoningEffort(result, info.ReasoningEffort) - } + // chat 终端 + claude 系模型:剥离 web_search_options(聚合器场景,详见函数注释) + if !responsesUpstream { + result = stripWebSearchOptionsForClaudeModels(result, info) } return result, nil } +// stripWebSearchOptionsForClaudeModels 在 chat 终端出口剥离 web_search_options。 +// 该参数在网关内部是 Responses→OpenAI→Claude 链的搜索载体(第二跳据此还原 +// web_search 工具),必须完整通过转换层;但当 chat 上游实际服务的是 claude 系模型 +// (聚合器把 openai chat 转发给 Claude 后端)时,聚合器会把它映射为无法在 chat 协议 +// 表达的原生搜索工具,模型发起的搜索 tool_use 被整个吞掉,返回空内容 + finish stop +// (线上实测:codex 经 chat 聚合器问天气,0 token 空响应)。 +// claude 原生渠道不走本适配器,不受影响;OpenAI 官方渠道的模型名不含 claude,同样不受影响。 +func stripWebSearchOptionsForClaudeModels(r io.Reader, info *common.RelayInfo) io.Reader { + model := info.OriginModelName + if info.ChannelMeta != nil && info.ChannelMeta.UpstreamModelName != "" { + model = info.ChannelMeta.UpstreamModelName + } + if !strings.Contains(strings.ToLower(model), "claude") { + return r + } + body, err := io.ReadAll(r) + if err != nil { + return r + } + var rawMap map[string]json.RawMessage + if err := json.Unmarshal(body, &rawMap); err != nil { + return bytes.NewReader(body) + } + if _, exists := rawMap["web_search_options"]; !exists { + return bytes.NewReader(body) + } + delete(rawMap, "web_search_options") + result, err := json.Marshal(rawMap) + if err != nil { + return bytes.NewReader(body) + } + return bytes.NewReader(result) +} + // DoRequest 发送请求到上游 func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { reqURL, err := a.GetRequestURL(info) @@ -468,51 +465,6 @@ func InjectStreamOptions(r io.Reader, info *common.RelayInfo) io.Reader { return bytes.NewReader(body) } -// injectReasoningEffort 注入 reasoning_effort 字段到请求体 -func injectReasoningEffort(r io.Reader, effort string) io.Reader { - body, err := io.ReadAll(r) - if err != nil { - return r - } - var rawMap map[string]json.RawMessage - if err := json.Unmarshal(body, &rawMap); err != nil { - return bytes.NewReader(body) - } - // 仅在客户端未显式设置时注入 - if _, exists := rawMap["reasoning_effort"]; !exists { - rawMap["reasoning_effort"], _ = json.Marshal(effort) - result, err := json.Marshal(rawMap) - if err != nil { - return bytes.NewReader(body) - } - return bytes.NewReader(result) - } - return bytes.NewReader(body) -} - -// injectResponsesReasoning 为 Responses 请求注入 reasoning.effort(对应 chat 的 reasoning_effort)。 -// Responses 协议无 reasoning_effort 字段,thinking 后缀映射为 reasoning: {"effort": ...}。 -func injectResponsesReasoning(r io.Reader, effort string) io.Reader { - body, err := io.ReadAll(r) - if err != nil { - return r - } - var rawMap map[string]json.RawMessage - if err := json.Unmarshal(body, &rawMap); err != nil { - return bytes.NewReader(body) - } - // 仅在客户端未显式设置 reasoning 时注入 - if _, exists := rawMap["reasoning"]; !exists { - rawMap["reasoning"], _ = json.Marshal(map[string]any{"effort": effort}) - result, err := json.Marshal(rawMap) - if err != nil { - return bytes.NewReader(body) - } - return bytes.NewReader(result) - } - return bytes.NewReader(body) -} - // isUpstreamOpenAIError 检查上游响应是否为标准 OpenAI 错误格式 // 标准格式: {"error": {"type": "...", "message": "...", ...}} func isUpstreamOpenAIError(body []byte) bool { diff --git a/relay/channel/openai/adaptor_responses_test.go b/relay/channel/openai/adaptor_responses_test.go index 24a9c4e..3455ad8 100644 --- a/relay/channel/openai/adaptor_responses_test.go +++ b/relay/channel/openai/adaptor_responses_test.go @@ -44,6 +44,10 @@ func TestAdaptor_GetRequestURL_ResponsesUpstream(t *testing.T) { {constant.RelayModeResponses, "https://upstream.example.com/v1/responses"}, {constant.RelayModeResponsesCompact, "https://upstream.example.com/v1/responses/compact"}, {constant.RelayModeChatCompletions, "https://upstream.example.com/v1/chat/completions"}, + // Gemini/Claude 入站经 relaykit 转为 chat 格式后打 /v1/chat/completions + //(回归:URL switch 漏加对应模式时 DoRequest 直接报 unsupported relay mode) + {constant.RelayModeGeminiChat, "https://upstream.example.com/v1/chat/completions"}, + {constant.RelayModeClaudeMessages, "https://upstream.example.com/v1/chat/completions"}, } for _, c := range cases { a := &Adaptor{} @@ -139,34 +143,6 @@ func TestAdaptor_ConvertRequest_ResponsesUpstream(t *testing.T) { } } -// TestAdaptor_ConvertRequest_ResponsesUpstream_Thinking 上游声明 Responses 协议时, -// thinking 后缀映射为 reasoning.effort,不注入 chat 的 reasoning_effort。 -func TestAdaptor_ConvertRequest_ResponsesUpstream_Thinking(t *testing.T) { - info := responsesUpstreamInfo(constant.RelayModeResponses, false) - info.ReasoningEffort = "high" - - body := []byte(`{"model":"gpt-4o","input":"say hi"}`) - a := &Adaptor{} - out, err := a.ConvertRequest(context.Background(), info, body) - if err != nil { - t.Fatalf("ConvertRequest error: %v", err) - } - raw, _ := io.ReadAll(out) - var m map[string]json.RawMessage - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("bad converted json: %v\n%s", err, raw) - } - if _, ok := m["reasoning_effort"]; ok { - t.Error("should NOT inject chat reasoning_effort for responses upstream") - } - var reasoning struct { - Effort string `json:"effort"` - } - if err := json.Unmarshal(m["reasoning"], &reasoning); err != nil || reasoning.Effort != "high" { - t.Errorf("reasoning.effort = %+v (err=%v), want high", reasoning, err) - } -} - // TestResponsesUsageToCommon 验证 Responses usage → common.Usage 映射。 func TestResponsesUsageToCommon(t *testing.T) { u := responsesUsageToCommon(&dto.ResponsesUsage{ @@ -356,72 +332,6 @@ func (f *fakeResponseRouteStore) Lookup(context.Context, int64, string) (common. func (f *fakeResponseRouteStore) Delete(context.Context, int64, string) {} -// TestChatCompletionToResponsesResponse_StoreFalseAndEcho 合成响应的保真度: -// store 恒为 false(合成响应不落上游存储,不可 retrieve); -// temperature/top_p/max_output_tokens/instructions 从请求快照 echo,快照缺失回退默认值。 -func TestChatCompletionToResponsesResponse_StoreFalseAndEcho(t *testing.T) { - buildChatResp := func() *dto.ChatCompletionResponse { - return &dto.ChatCompletionResponse{ - ID: "abc123", - Created: 1700000000, - Model: "gpt-4o", - Choices: []dto.Choice{{ - Index: 0, - Message: dto.Message{Role: "assistant", Content: "hello"}, - FinishReason: "stop", - }}, - Usage: dto.UsageWithDetails{ - PromptTokensDetails: &dto.TokenDetails{}, - CompletionTokenDetails: &dto.TokenDetails{}, - }, - } - } - - t.Run("echo from request snapshot", func(t *testing.T) { - temp, topP := 0.7, 0.9 - maxOut := uint(1024) - info := &common.RelayInfo{ - OriginModelName: "gpt-4o", - ResponsesRequest: &dto.OpenAIResponsesRequest{ - Temperature: &temp, - TopP: &topP, - MaxOutputTokens: &maxOut, - Instructions: json.RawMessage(`"be brief"`), - }, - } - resp := chatCompletionToResponsesResponse(buildChatResp(), info) - if resp.Store { - t.Error("store should be false(合成响应不可 retrieve)") - } - if resp.Temperature == nil || *resp.Temperature != 0.7 { - t.Errorf("temperature = %v, want 0.7", resp.Temperature) - } - if resp.TopP == nil || *resp.TopP != 0.9 { - t.Errorf("top_p = %v, want 0.9", resp.TopP) - } - if resp.MaxOutputTokens == nil || *resp.MaxOutputTokens != 1024 { - t.Errorf("max_output_tokens = %v, want 1024", resp.MaxOutputTokens) - } - if instr, ok := resp.Instructions.(json.RawMessage); !ok || string(instr) != `"be brief"` { - t.Errorf("instructions = %v, want echo", resp.Instructions) - } - }) - - t.Run("nil snapshot falls back to defaults", func(t *testing.T) { - info := &common.RelayInfo{OriginModelName: "gpt-4o"} - resp := chatCompletionToResponsesResponse(buildChatResp(), info) - if resp.Store { - t.Error("store should be false") - } - if resp.Temperature == nil || *resp.Temperature != 1.0 { - t.Errorf("temperature = %v, want default 1.0", resp.Temperature) - } - if resp.TopP == nil || *resp.TopP != 1.0 { - t.Errorf("top_p = %v, want default 1.0", resp.TopP) - } - }) -} - // TestBuildResponsesObjectMap_StoreFalseEcho 流式合成 response 对象同样 store:false + echo。 func TestBuildResponsesObjectMap_StoreFalseEcho(t *testing.T) { temp := 0.5 @@ -605,70 +515,6 @@ func TestAdaptor_DoResponse_ResponsesUpstreamStream_ModelMapped(t *testing.T) { } } -// TestAdaptor_ConvertRequest_ChatViaResponsesBridge responses-only 上游桥接: -// chat 请求体转换为 Responses 格式(messages→input),thinking 后缀映射 reasoning.effort, -// 不注入 chat 专属 stream_options。 -func TestAdaptor_ConvertRequest_ChatViaResponsesBridge(t *testing.T) { - info := responsesUpstreamInfo(constant.RelayModeChatCompletions, true) - info.InboundFormat = constant.RelayFormatOpenAI - info.ClientFormat = constant.RelayFormatOpenAI - info.OriginModelName = "gpt-4o" - info.ChannelMeta.SupportsResponses = false - info.ChannelMeta.ChatViaResponses = true - info.UseResponsesAPI = true - - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}],"stream":true}`) - a := &Adaptor{} - out, err := a.ConvertRequest(context.Background(), info, body) - if err != nil { - t.Fatalf("ConvertRequest error: %v", err) - } - raw, _ := io.ReadAll(out) - var m map[string]json.RawMessage - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("bad bridged json: %v\n%s", err, raw) - } - if _, ok := m["messages"]; ok { - t.Error("bridge should convert messages to responses input") - } - if _, ok := m["input"]; !ok { - t.Error("bridge output missing input field") - } - if _, ok := m["stream_options"]; ok { - t.Error("bridge should NOT inject chat stream_options") - } - if stream := string(m["stream"]); stream != "true" { - t.Errorf("stream = %s, want true", stream) - } -} - -// TestAdaptor_ConvertRequest_ChatViaResponsesBridge_ModelMapped 桥接 + 模型映射: -// 转换器应将模型名替换为上游模型名。 -func TestAdaptor_ConvertRequest_ChatViaResponsesBridge_ModelMapped(t *testing.T) { - info := responsesUpstreamInfo(constant.RelayModeChatCompletions, false) - info.InboundFormat = constant.RelayFormatOpenAI - info.ClientFormat = constant.RelayFormatOpenAI - info.ChannelMeta.IsModelMapped = true - info.ChannelMeta.SupportsResponses = false - info.ChannelMeta.ChatViaResponses = true - info.UseResponsesAPI = true - - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) - a := &Adaptor{} - out, err := a.ConvertRequest(context.Background(), info, body) - if err != nil { - t.Fatalf("ConvertRequest error: %v", err) - } - raw, _ := io.ReadAll(out) - var m map[string]json.RawMessage - if err := json.Unmarshal(raw, &m); err != nil { - t.Fatalf("bad bridged json: %v", err) - } - if got := string(m["model"]); got != `"gpt-4o-upstream"` { - t.Errorf("model = %s, want gpt-4o-upstream", got) - } -} - // responsesInboundInfo responses 入站 + 上游 chat 渠道(无 responses 协议能力), // 响应侧走 chat→responses 桥接(handleResponsesInboundStream)。 func responsesInboundInfo(isStream bool) *common.RelayInfo { @@ -678,28 +524,6 @@ func responsesInboundInfo(isStream bool) *common.RelayInfo { return info } -// TestExtractStreamEmbeddedError 内嵌错误对象检测: -// "error":null 与无 error 键不算错误,对象/字符串错误都要识别。 -func TestExtractStreamEmbeddedError(t *testing.T) { - if _, ok := extractStreamEmbeddedError([]byte(`{"id":"1","choices":[]}`)); ok { - t.Error("chunk without error key should not be detected") - } - if _, ok := extractStreamEmbeddedError([]byte(`{"error":null,"choices":[]}`)); ok { - t.Error(`"error":null should not be detected`) - } - if body, ok := extractStreamEmbeddedError([]byte(`{"error":{"type":"rate_limit_error","message":"limited"}}`)); !ok { - t.Error("error object should be detected") - } else if !strings.Contains(string(body), "rate_limit_error") { - t.Errorf("error body = %s", string(body)) - } - if _, ok := extractStreamEmbeddedError([]byte(`{"error":"overloaded"}`)); !ok { - t.Error("string error should be detected") - } - if _, ok := extractStreamEmbeddedError([]byte(`not json`)); ok { - t.Error("invalid json should not be detected") - } -} - // TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorBeforeEvents // 上游 200 + SSE 但首个 data 行即内嵌错误对象(部分聚合商的出错形态): // 必须返回 upstream error 并向客户端透传错误体,而非静默合成空 response.completed。 @@ -729,10 +553,15 @@ func TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorBeforeEvents(t * t.Fatalf("error should be *constant.RelayError, got %T", err) } if !relayErr.ResponseWritten { - t.Error("ResponseWritten should be true after writing error body to client") + t.Error("ResponseWritten should be true(SSE 头已提交,上层不得重写响应体)") + } + // Responses 协议要求终止态事件:桥接层补发 response.failed(含错误信息), + // 不得合成假成功的 response.completed + if !strings.Contains(rec.Body.String(), "response.failed") { + t.Errorf("should emit response.failed on stream error: %s", rec.Body.String()) } if !strings.Contains(rec.Body.String(), "rate_limit_error") { - t.Errorf("error body not passed through: %s", rec.Body.String()) + t.Errorf("failed event should carry upstream error message: %s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "response.completed") { t.Errorf("should not synthesize empty response.completed on upstream error: %s", rec.Body.String()) @@ -842,12 +671,12 @@ func TestAdaptor_DoResponse_ResponsesInboundStream_NonSSEJSONBody(t *testing.T) t.Fatalf("error should be *constant.RelayError, got %T", err) } if !relayErr.ResponseWritten { - t.Error("ResponseWritten should be true after writing error body to client") + t.Error("ResponseWritten should be true(SSE 头已提交,状态码不可改写)") } - // SetEventStreamHeaders 已提前提交 HTTP 200,状态码无法改写为 502; - // 但错误体必须写入(客户端可见明确错误而非空 SSE 流) - if !strings.Contains(rec.Body.String(), "upstream_protocol_mismatch") { - t.Errorf("error body should contain protocol mismatch code: %s", rec.Body.String()) + // 非 chat 格式的上游体:桥接层补发 response.failed 终止事件 + //(SetEventStreamHeaders 已提交 HTTP 200,无法改为 502),不得合成假成功 + if !strings.Contains(rec.Body.String(), "response.failed") { + t.Errorf("should emit response.failed on protocol mismatch: %s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "response.completed") { t.Errorf("should not synthesize empty response.completed: %s", rec.Body.String()) @@ -885,12 +714,12 @@ func TestAdaptor_DoResponse_ResponsesInboundStream_UnparseableChunks(t *testing. t.Fatalf("error should be *constant.RelayError, got %T", err) } if !relayErr.ResponseWritten { - t.Error("ResponseWritten should be true after writing error body to client") + t.Error("ResponseWritten should be true(SSE 头已提交,状态码不可改写)") } if strings.Contains(rec.Body.String(), "response.completed") { t.Errorf("should not synthesize empty response.completed:\n%s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "upstream_protocol_mismatch") { - t.Errorf("error body should contain protocol mismatch code: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "response.failed") { + t.Errorf("should emit response.failed on unparseable stream: %s", rec.Body.String()) } } diff --git a/relay/channel/openai/claude_response.go b/relay/channel/openai/claude_response.go index 6745051..95bfe30 100644 --- a/relay/channel/openai/claude_response.go +++ b/relay/channel/openai/claude_response.go @@ -1,24 +1,93 @@ package openai import ( - "bufio" "context" "encoding/json" - "errors" "fmt" "io" "net/http" - "strings" - "time" - - "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" ) +// Claude 入站(客户端说 Claude、上游说 OpenAI)的响应侧接线。 +// 协议转换本身在 relaykit(internal/oai_chat 的 OpenAIToClaude 转换器); +// 本文件只保留宿主职责:HTTP 状态码处理、错误按 Claude 原生格式透传、 +// 响应写出与计费用量提取。 + +// writeOpenAIErrorAsClaude 把上游 OpenAI 错误体改写为 Claude 原生错误格式。 +// +// 必须解析后重建、而非把上游响应体整个塞进 message: +// - 塞原文会让客户端看到一坨转义 JSON 而非人类可读消息(曾经的行为); +// - error.type 是 Anthropic SDK 的重试与分类依据,硬编码 api_error 会让 +// 限流(rate_limit_error)被当成服务端故障,客户端退避策略失效。 +// +// 与 writeOpenAIErrorAsGemini 对称:两者共用 openAIErrorTypeToHTTPStatus 的状态码修正。 +func writeOpenAIErrorAsClaude(writer http.ResponseWriter, body []byte, defaultStatusCode int) { + var openaiErr struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code any `json:"code"` + } `json:"error"` + } + statusCode := defaultStatusCode + message := string(body) + errType := openAIErrorTypeToClaude("", defaultStatusCode) + + if err := json.Unmarshal(body, &openaiErr); err == nil && openaiErr.Error.Message != "" { + message = openaiErr.Error.Message + statusCode = openAIErrorTypeToHTTPStatus(openaiErr.Error.Type, defaultStatusCode) + errType = openAIErrorTypeToClaude(openaiErr.Error.Type, statusCode) + } + + claudeErr, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]any{"type": errType, "message": message}, + }) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(statusCode) + _, _ = writer.Write(claudeErr) +} + +// openAIErrorTypeToClaude 将 OpenAI error type 映射为 Anthropic 错误类型。 +// 上游未给出 type(或为未知值)时按状态码兜底,仍优于一律 api_error。 +func openAIErrorTypeToClaude(errorType string, statusCode int) string { + switch errorType { + case "invalid_request_error": + return "invalid_request_error" + case "authentication_error": + return "authentication_error" + case "permission_error": + return "permission_error" + case "not_found_error": + return "not_found_error" + case "rate_limit_error", "insufficient_quota": + return "rate_limit_error" + } + switch statusCode { + case http.StatusBadRequest: + return "invalid_request_error" + case http.StatusUnauthorized: + return "authentication_error" + case http.StatusForbidden: + return "permission_error" + case http.StatusNotFound: + return "not_found_error" + case http.StatusRequestEntityTooLarge: + return "request_too_large" + case http.StatusTooManyRequests: + return "rate_limit_error" + case http.StatusServiceUnavailable: + return "overloaded_error" + default: + return "api_error" + } +} + // handleClaudeInboundNonStream 将 OpenAI 非流式响应转换为 Claude 格式 func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { defer resp.Body.Close() @@ -30,33 +99,28 @@ func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info if resp.StatusCode != http.StatusOK { // 将上游错误转换为 Claude 格式透传给客户端 - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(resp.StatusCode) - claudeErr, _ := json.Marshal(map[string]any{ - "type": "error", - "error": map[string]any{"type": "api_error", "message": string(body)}, - }) - _, _ = writer.Write(claudeErr) + writeOpenAIErrorAsClaude(writer, body, resp.StatusCode) upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) upstreamErr.ResponseWritten = true return &common.Usage{}, upstreamErr } - var openaiResp dto.ChatCompletionResponse - if err := json.Unmarshal(body, &openaiResp); err != nil { - return nil, fmt.Errorf("invalid response body: %w", err) + // relaykit 响应转换(唯一路径,hard-fail) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("openai adaptor: no relaykit converter for claude client response", nil) + } + if convErr != nil { + return nil, fmt.Errorf("invalid response body: %w", convErr) } - - // 转换为 Claude 格式 - claudeResp := openAIToClaudeResponse(&openaiResp, info) - - respBody, _ := json.Marshal(claudeResp) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respBody) + _, _ = writer.Write(convertedBody) + // 计费用量取上游 OpenAI 原始口径(prompt 含缓存,cached 为其子集) usage := &common.Usage{} - if len(openaiResp.Choices) > 0 { + var openaiResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &openaiResp); err == nil && len(openaiResp.Choices) > 0 { usage.PromptTokens = openaiResp.Usage.PromptTokens usage.CompletionTokens = openaiResp.Usage.CompletionTokens usage.TotalTokens = openaiResp.Usage.TotalTokens @@ -69,429 +133,22 @@ func handleClaudeInboundNonStream(ctx context.Context, resp *http.Response, info // handleClaudeInboundStream 将 OpenAI SSE 流转换为 Claude SSE 流 func handleClaudeInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(resp.StatusCode) - claudeErr, _ := json.Marshal(map[string]any{ - "type": "error", - "error": map[string]any{"type": "api_error", "message": string(body)}, - }) - _, _ = writer.Write(claudeErr) + writeOpenAIErrorAsClaude(writer, body, resp.StatusCode) upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) upstreamErr.ResponseWritten = true return &common.Usage{}, upstreamErr } - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - msgID := fmt.Sprintf("msg_%s", info.RequestID) - modelName := info.OriginModelName - if info.ChannelMeta.IsModelMapped { - modelName = info.ChannelMeta.UpstreamModelName + // relaykit 流式转换(唯一路径,hard-fail):桥接层负责 Claude 事件帧写出与 usage 捕获 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } - - var ( - usage common.Usage - startSent bool - finishReason string - contentIndex int - inputTokens int - outputTokens int - cachedTokens int // prompt_tokens_details.cached_tokens(OpenAI 口径:已含于 prompt_tokens) - currentBlockType string // 跟踪当前 block 类型: "text" 或 "tool_use" - transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 - ) - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断:带上已收到的 usage(OpenAI usage 在最后一个 chunk,中断时通常缺失), - // 缺失部分由兜底估算补齐(输出按已转发文本 2 字符/token,输入用请求侧估算值) - usage.PromptTokens = inputTokens - usage.CompletionTokens = outputTokens - usage.TotalTokens = inputTokens + outputTokens - helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen) - return &usage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" || strings.HasPrefix(line, "event:") { - continue - } - if !strings.HasPrefix(line, "data:") { - continue - } - - data, _ := helper.ExtractSSEData(line) - - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } - - if data == "[DONE]" { - // 关闭当前 content block(如果有的话) - if currentBlockType != "" { - blockStop := dto.ClaudeResponse{ - Type: "content_block_stop", - Index: intPtr(contentIndex), - } - writeClaudeSSE(writer, "content_block_stop", &blockStop) - contentIndex++ - currentBlockType = "" - } - - // 发送 message_delta(stop_reason)+ message_stop。 - // Claude 语义:input_tokens 不含 cache_read(OpenAI 的 prompt_tokens 含 cached,扣减后映射); - // OpenAI 的 usage 在流尾才返回、message_start 时不可得,最终用量在此补报 - reason := common.OpenAIFinishReasonToClaude(finishReason) - deltaInput := inputTokens - cachedTokens - if deltaInput < 0 { - deltaInput = 0 - } - delta := dto.ClaudeResponse{ - Type: "message_delta", - Delta: &dto.ClaudeDelta{ - StopReason: strPtr(reason), - }, - Usage: &dto.ClaudeUsage{ - InputTokens: deltaInput, - CacheReadInputTokens: cachedTokens, - OutputTokens: outputTokens, - }, - } - writeClaudeSSE(writer, "message_delta", &delta) - - stopEvent := dto.ClaudeResponse{Type: "message_stop"} - writeClaudeSSE(writer, "message_stop", &stopEvent) - - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - break - } - - var streamResp dto.ChatCompletionStreamResponse - if err := json.Unmarshal([]byte(data), &streamResp); err != nil { - continue - } - - // 提取 usage - if streamResp.Usage != nil { - inputTokens = streamResp.Usage.PromptTokens - outputTokens = streamResp.Usage.CompletionTokens - if streamResp.Usage.PromptTokensDetails != nil { - cachedTokens = streamResp.Usage.PromptTokensDetails.CachedTokens - } - // 缓存/思考明细透传给计费;OpenAI 的 prompt_tokens 含 cached(子集语义), - // 置 CacheIncludedInPrompt 让计费按明细扣减缓存部分,避免缓存按 input 全价计费 - usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.PromptTokensDetails) - usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.CompletionTokenDetails) - usage.CacheIncludedInPrompt = true - } - - // 发送 message_start(首块数据时) - if !startSent { - startEvent := dto.ClaudeResponse{ - Type: "message_start", - Message: &dto.ClaudeMessageInfo{ - ID: msgID, - Type: "message", - Role: "assistant", - Content: []dto.ClaudeContentBlock{}, - Model: modelName, - StopReason: nil, - StopSequence: nil, - Usage: &dto.ClaudeUsage{ - InputTokens: inputTokens, - OutputTokens: 0, - }, - }, - } - writeClaudeSSE(writer, "message_start", &startEvent) - startSent = true - } - - // 处理每个 choice delta - for _, choice := range streamResp.Choices { - // 文本内容 - if text, ok := choice.Delta.Content.(string); ok && text != "" { - transferredTextLen += len(text) - // 如果当前 block 不是 text,先关闭前一个 block - if currentBlockType != "" && currentBlockType != "text" { - blockStop := dto.ClaudeResponse{ - Type: "content_block_stop", - Index: intPtr(contentIndex), - } - writeClaudeSSE(writer, "content_block_stop", &blockStop) - contentIndex++ - } - - // content_block_start(首次文本输出) - if currentBlockType != "text" { - blockStart := dto.ClaudeResponse{ - Type: "content_block_start", - Index: intPtr(contentIndex), - ContentBlock: &dto.ClaudeContentBlock{ - Type: "text", - Text: strPtr(""), - }, - } - writeClaudeSSE(writer, "content_block_start", &blockStart) - currentBlockType = "text" - } - - delta := dto.ClaudeResponse{ - Type: "content_block_delta", - Index: intPtr(contentIndex), - Delta: &dto.ClaudeDelta{ - Type: "text_delta", - Text: strPtr(text), - }, - } - writeClaudeSSE(writer, "content_block_delta", &delta) - } - - // 推理内容(thinking) - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { - transferredTextLen += len(*choice.Delta.ReasoningContent) - // 如果当前 block 不是 thinking,先关闭前一个 block - if currentBlockType != "" && currentBlockType != "thinking" { - blockStop := dto.ClaudeResponse{ - Type: "content_block_stop", - Index: intPtr(contentIndex), - } - writeClaudeSSE(writer, "content_block_stop", &blockStop) - contentIndex++ - } - - if currentBlockType != "thinking" { - blockStart := dto.ClaudeResponse{ - Type: "content_block_start", - Index: intPtr(contentIndex), - ContentBlock: &dto.ClaudeContentBlock{ - Type: "thinking", - Thinking: strPtr(""), - }, - } - writeClaudeSSE(writer, "content_block_start", &blockStart) - currentBlockType = "thinking" - } - - delta := dto.ClaudeResponse{ - Type: "content_block_delta", - Index: intPtr(contentIndex), - Delta: &dto.ClaudeDelta{ - Type: "thinking_delta", - Thinking: choice.Delta.ReasoningContent, - }, - } - writeClaudeSSE(writer, "content_block_delta", &delta) - } - - // 工具调用 - for _, tc := range choice.Delta.ToolCalls { - // content_block_start 用于 tool_use(仅在 function name 出现时) - if tc.Function.Name != "" { - // 先关闭前一个 block - if currentBlockType != "" { - blockStop := dto.ClaudeResponse{ - Type: "content_block_stop", - Index: intPtr(contentIndex), - } - writeClaudeSSE(writer, "content_block_stop", &blockStop) - contentIndex++ - } - - blockStart := dto.ClaudeResponse{ - Type: "content_block_start", - Index: intPtr(contentIndex), - ContentBlock: &dto.ClaudeContentBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Function.Name, - Input: map[string]any{}, - }, - } - writeClaudeSSE(writer, "content_block_start", &blockStart) - currentBlockType = "tool_use" - } - - // content_block_delta 用于工具参数 - if tc.Function.Arguments != "" { - delta := dto.ClaudeResponse{ - Type: "content_block_delta", - Index: intPtr(contentIndex), - Delta: &dto.ClaudeDelta{ - Type: "input_json_delta", - PartialJSON: &tc.Function.Arguments, - }, - } - writeClaudeSSE(writer, "content_block_delta", &delta) - } - } - - // finish_reason - if choice.FinishReason != nil { - finishReason = *choice.FinishReason - } - } - } - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - // 读流出错:带上已收到的 usage,避免整段按零值计费漏计 - usage.PromptTokens = inputTokens - usage.CompletionTokens = outputTokens - usage.TotalTokens = inputTokens + outputTokens - usage.CacheIncludedInPrompt = true - return &usage, fmt.Errorf("stream scanner error: %w", err) - } - - if info.StreamStatus.GetEndReason() == "" { - // 流意外结束,发送终止事件 - if currentBlockType != "" { - blockStop := dto.ClaudeResponse{ - Type: "content_block_stop", - Index: intPtr(contentIndex), - } - writeClaudeSSE(writer, "content_block_stop", &blockStop) - } - stopEvent := dto.ClaudeResponse{Type: "message_stop"} - writeClaudeSSE(writer, "message_stop", &stopEvent) - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - - usage.PromptTokens = inputTokens - usage.CompletionTokens = outputTokens - usage.TotalTokens = inputTokens + outputTokens - usage.CacheIncludedInPrompt = true - - return &usage, nil -} - -// openAIToClaudeResponse 将 OpenAI ChatCompletion 响应转换为 Claude Messages 响应 -func openAIToClaudeResponse(openaiResp *dto.ChatCompletionResponse, info *common.RelayInfo) dto.ClaudeResponse { - content := make([]dto.ClaudeContentBlock, 0) - var textParts []string - var thinkingParts []string - var toolCalls []dto.ClaudeContentBlock - - if len(openaiResp.Choices) > 0 { - choice := openaiResp.Choices[0] - - // 提取文本 - if text, ok := choice.Message.Content.(string); ok && text != "" { - textParts = append(textParts, text) - } - - // 提取思维内容 - if choice.Message.ReasoningContent != nil && *choice.Message.ReasoningContent != "" { - thinkingParts = append(thinkingParts, *choice.Message.ReasoningContent) - } - - // 提取工具调用 - for _, tc := range choice.Message.ToolCalls { - var inputObj any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputObj); err != nil { - inputObj = map[string]any{} - } - toolCalls = append(toolCalls, dto.ClaudeContentBlock{ - Type: "tool_use", - ID: tc.ID, - Name: tc.Function.Name, - Input: inputObj, - }) - } - } - - // 添加思维块(如果有) - for _, thinking := range thinkingParts { - content = append(content, dto.ClaudeContentBlock{ - Type: "thinking", - Thinking: strPtr(thinking), - }) - } - - // 添加文本块 - for _, text := range textParts { - content = append(content, dto.ClaudeContentBlock{ - Type: "text", - Text: strPtr(text), - }) - } - - // 添加工具调用块 - content = append(content, toolCalls...) - - if len(content) == 0 { - content = append(content, dto.ClaudeContentBlock{ - Type: "text", - Text: strPtr(""), - }) - } - - modelName := openaiResp.Model - if info.ChannelMeta.IsModelMapped { - modelName = info.OriginModelName - } - - stopReason := "end_turn" - if len(openaiResp.Choices) > 0 { - stopReason = common.OpenAIFinishReasonToClaude(openaiResp.Choices[0].FinishReason) - } - - // OpenAI 的 prompt_tokens 已含 cached(子集语义),Claude 的 input_tokens 不含 cache_read, - // 需扣减后映射,否则 Claude 客户端按本协议语义计账时缓存部分被重复计入。 - // PromptTokensDetails 可能为 nil(多数 OpenAI 兼容上游不返回该字段),必须判空。 - cachedTokens := 0 - if openaiResp.Usage.PromptTokensDetails != nil { - cachedTokens = openaiResp.Usage.PromptTokensDetails.CachedTokens - } - inputTokens := openaiResp.Usage.PromptTokens - cachedTokens - if inputTokens < 0 { - inputTokens = 0 - } - - return dto.ClaudeResponse{ - ID: fmt.Sprintf("msg_%s", info.RequestID), - Type: "message", - Role: "assistant", - Content: content, - StopReason: stopReason, - StopSequence: nil, - Model: modelName, - Usage: &dto.ClaudeUsage{ - InputTokens: inputTokens, - OutputTokens: openaiResp.Usage.CompletionTokens, - CacheReadInputTokens: cachedTokens, - }, - } -} - -// writeClaudeSSE 写入 Claude 格式的 SSE 事件(池化缓冲,整帧单次写出,零分配)。 -// 序列化失败时跳过该事件并记日志:写出的会是 `event: X\ndata: \n\n`,客户端拿到 data 为空的 -// 事件后 JSON.parse("") 直接抛错并中止整个请求(网关侧只会看到 ctx 取消,记成 client_gone)。 -// 写客户端失败沿用既有行为不逐事件记录 —— 由主循环的 ctx 检查与上层中断结算统一处理。 -func writeClaudeSSE(w http.ResponseWriter, eventType string, data any) { - if err := helper.WriteSSEEventJSON(w, eventType, data); errors.Is(err, helper.ErrSSEPayloadMarshal) { - g.Log().Errorf(context.Background(), "[ClaudeSSE] marshal %s event failed, event skipped: %v", eventType, err) - } -} - -// intPtr 返回 int 的指针 -func intPtr(v int) *int { - return &v -} - -// strPtr 返回 string 的指针 -func strPtr(v string) *string { - return &v + return nil, constant.NewChannelError("openai adaptor: relaykit stream converter unavailable for claude client", nil) } diff --git a/relay/channel/openai/claude_response_test.go b/relay/channel/openai/claude_response_test.go index 1a558dd..2ec47a4 100644 --- a/relay/channel/openai/claude_response_test.go +++ b/relay/channel/openai/claude_response_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/qianfree/team-api/relay/common" - "github.com/qianfree/team-api/relay/dto" + "github.com/qianfree/team-api/relay/constant" ) // newClaudeInboundTestInfo 构造 Claude 入站转换测试用的 RelayInfo。 @@ -18,65 +18,14 @@ func newClaudeInboundTestInfo() *common.RelayInfo { RequestID: "req-claude-inbound", OriginModelName: "gpt-4o", StreamStatus: common.NewStreamStatus(), - ChannelMeta: &common.ChannelMeta{}, - } -} - -// TestOpenAIToClaudeResponse_UsageNilDetails 上游不返回 prompt_tokens_details(多数 OpenAI 兼容上游) -// 时不得空指针 panic,且无缓存可扣减。 -func TestOpenAIToClaudeResponse_UsageNilDetails(t *testing.T) { - info := newClaudeInboundTestInfo() - openaiResp := &dto.ChatCompletionResponse{ - ID: "chatcmpl-1", - Model: "gpt-4o", - Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, - Usage: dto.UsageWithDetails{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, - } - - resp := openAIToClaudeResponse(openaiResp, info) // PromptTokensDetails 为 nil,此处原会 panic - - if resp.Usage.InputTokens != 100 { - t.Errorf("InputTokens = %d, want 100 (no cache to deduct)", resp.Usage.InputTokens) - } - if resp.Usage.CacheReadInputTokens != 0 { - t.Errorf("CacheReadInputTokens = %d, want 0", resp.Usage.CacheReadInputTokens) - } - if resp.Usage.OutputTokens != 20 { - t.Errorf("OutputTokens = %d, want 20", resp.Usage.OutputTokens) - } -} - -// TestOpenAIToClaudeResponse_UsageWithCache 上游返回缓存明细时,usage 需按 Claude 语义映射: -// OpenAI 的 prompt_tokens 含 cached(子集),Claude 的 input_tokens 不含 cache_read, -// 直接透传会导致客户端按本协议语义计账时缓存部分重复计入。 -func TestOpenAIToClaudeResponse_UsageWithCache(t *testing.T) { - info := newClaudeInboundTestInfo() - openaiResp := &dto.ChatCompletionResponse{ - ID: "chatcmpl-2", - Model: "gpt-4o", - Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, - Usage: dto.UsageWithDetails{ - PromptTokens: 100, - CompletionTokens: 20, - TotalTokens: 120, - PromptTokensDetails: &dto.TokenDetails{CachedTokens: 30}, + // 客户端说 Claude、上游说 OpenAI:命中 relaykit 的 openai→claude 流式转换器 + InboundFormat: constant.RelayFormatClaude, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), }, } - - resp := openAIToClaudeResponse(openaiResp, info) - - if resp.Usage.InputTokens != 70 { - t.Errorf("InputTokens = %d, want 70 (prompt 100 - cached 30)", resp.Usage.InputTokens) - } - if resp.Usage.CacheReadInputTokens != 30 { - t.Errorf("CacheReadInputTokens = %d, want 30", resp.Usage.CacheReadInputTokens) - } } -// TestHandleClaudeInboundStream_CacheUsage 流式路径缓存用量: -// 1. 计费 usage 携带缓存明细并置 CacheIncludedInPrompt(计费扣减缓存部分,避免按 input 全价计费); -// 2. 最终 message_delta 按 Claude 语义补报扣减后的 input_tokens 与 cache_read_input_tokens -// (OpenAI 的 usage 在流尾才返回,message_start 无法提供真实值)。 func TestHandleClaudeInboundStream_CacheUsage(t *testing.T) { sse := `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"}}]} diff --git a/relay/channel/openai/converter.go b/relay/channel/openai/converter.go index c42bcab..4287d2d 100644 --- a/relay/channel/openai/converter.go +++ b/relay/channel/openai/converter.go @@ -583,10 +583,17 @@ func g2oConvertToolConfig(toolConfig any) any { } func g2oConvertThinkingConfig(tc *dto.GeminiThinkingConfig) string { - if tc.ThoughtBudget == nil { + if tc.ThinkingBudget == nil { + // Gemini 3 客户端可能只带 thinkingLevel,按档位回退 + switch strings.ToLower(tc.ThinkingLevel) { + case "low": + return "low" + case "high": + return "high" + } return "medium" } - budget := *tc.ThoughtBudget + budget := *tc.ThinkingBudget switch { case budget <= 2048: return "low" @@ -717,6 +724,34 @@ type r2cInputItem struct { // function_call 项字段(Responses 历史中的助手工具调用) Name string `json:"name,omitempty"` Arguments string `json:"arguments,omitempty"` + // reasoning 项的思考文本(OpenAI 官方形态在 summary[],部分聚合器直连输出放 content[]) + Summary []r2cTextPart `json:"summary,omitempty"` +} + +// r2cTextPart Responses 输出项中文本部件的最小读取形态。 +type r2cTextPart struct { + Text string `json:"text"` +} + +// r2cReasoningText 提取 reasoning 项的思考文本,summary 与 content 两种形态按序拼接。 +func r2cReasoningText(item r2cInputItem) string { + var parts []string + for _, s := range item.Summary { + if s.Text != "" { + parts = append(parts, s.Text) + } + } + if len(item.Content) > 0 { + var contentParts []r2cTextPart + if err := json.Unmarshal(item.Content, &contentParts); err == nil { + for _, c := range contentParts { + if c.Text != "" { + parts = append(parts, c.Text) + } + } + } + } + return strings.Join(parts, "\n") } type r2cContentPart struct { @@ -755,11 +790,26 @@ func r2cConvertInputToMessages(input json.RawMessage) ([]map[string]any, error) // 连续的 function_call 项聚合为一条 assistant 消息(chat 协议的 tool_calls 数组语义), // 其后的 function_call_output 转为引用对应 tool_call_id 的 tool 消息 var pendingToolCalls []map[string]any + // reasoning 项的思考文本:挂到下一条 assistant 消息。DeepSeek 等推理上游的 thinking + // 模式要求多轮历史传回 reasoning_content,丢弃会 400(与 relaykit 侧镜像实现同口径) + var pendingReasoning string + takeReasoning := func() any { + if pendingReasoning == "" { + return nil + } + r := pendingReasoning + pendingReasoning = "" + return r + } flushToolCalls := func() { if len(pendingToolCalls) == 0 { return } - messages = append(messages, map[string]any{"role": "assistant", "content": nil, "tool_calls": pendingToolCalls}) + msg := map[string]any{"role": "assistant", "content": nil, "tool_calls": pendingToolCalls} + if r := takeReasoning(); r != nil { + msg["reasoning_content"] = r + } + messages = append(messages, msg) pendingToolCalls = nil } for _, raw := range items { @@ -771,6 +821,13 @@ func r2cConvertInputToMessages(input json.RawMessage) ([]map[string]any, error) case "message": flushToolCalls() if msg := r2cConvertMessage(item); msg != nil { + if msg["role"] == "assistant" { + if r := takeReasoning(); r != nil { + msg["reasoning_content"] = r + } + } else { + pendingReasoning = "" // user 消息前的 reasoning 无人可挂,丢弃 + } messages = append(messages, msg) } case "function_call": @@ -790,12 +847,16 @@ func r2cConvertInputToMessages(input json.RawMessage) ([]map[string]any, error) flushToolCalls() messages = append(messages, map[string]any{"role": "tool", "tool_call_id": item.CallID, "content": item.Output}) case "reasoning": - // reasoning 项(含加密思考内容)无 chat 协议对应物,跳过 - continue + pendingReasoning = r2cReasoningText(item) default: flushToolCalls() if item.Role != "" { if msg := r2cConvertMessage(item); msg != nil { + if msg["role"] == "assistant" { + if r := takeReasoning(); r != nil { + msg["reasoning_content"] = r + } + } messages = append(messages, msg) } } @@ -952,297 +1013,6 @@ func r2cParseTextFormat(raw json.RawMessage) map[string]any { } } -// ===== OpenAI → Responses 请求转换(Responses API Bridge) ===== - -// ConvertOpenAIToResponses 将 Chat Completions 请求体转换为 Responses API 请求体 -func ConvertOpenAIToResponses(body []byte, info *common.RelayInfo) ([]byte, error) { - var req dto.GeneralOpenAIRequest - if err := json.Unmarshal(body, &req); err != nil { - return nil, fmt.Errorf("parse chat completions request: %w", err) - } - - respReq := make(map[string]any) - if info.ChannelMeta.IsModelMapped { - respReq["model"] = info.ChannelMeta.UpstreamModelName - } else { - respReq["model"] = req.Model - } - - if instructions := c2rExtractInstructions(req.Messages); instructions != "" { - respReq["instructions"] = instructions - } - respReq["input"] = c2rConvertMessagesToInput(req.Messages) - - if req.Stream != nil { - respReq["stream"] = *req.Stream - } - if req.Temperature != nil { - respReq["temperature"] = *req.Temperature - } - if req.TopP != nil { - respReq["top_p"] = *req.TopP - } - // prompt_cache_key 是官方 Responses API 参数,原样透传; - // presence/frequency penalty 不属于官方 Responses API,透传会被严格上游 - // (如 api.openai.com)以未知参数拒绝,chat 入站时不注入、静默丢弃。 - if req.PromptCacheKey != "" { - respReq["prompt_cache_key"] = req.PromptCacheKey - } - if maxTokens := c2rGetMaxTokens(req); maxTokens > 0 { - respReq["max_output_tokens"] = maxTokens - } - if req.ResponseFormat != nil { - if tf := c2rBuildTextFormat(req.ResponseFormat); tf != nil { - respReq["text"] = tf - } - } - // 桥接的响应不可被 chat 客户端经 previous_response_id 引用(chat 协议无此概念), - // 显式 store:false 避免上游无谓存储;渠道配置 DisableStore 时 SanitizeFields 会删掉该字段 - respReq["store"] = false - if req.ReasoningEffort != "" { - respReq["reasoning"] = map[string]any{"effort": req.ReasoningEffort, "summary": "detailed"} - } - if len(req.Tools) > 0 { - if respTools := c2rConvertTools(req.Tools); len(respTools) > 0 { - respReq["tools"] = respTools - } - } - if req.ToolChoice != nil { - respReq["tool_choice"] = c2rConvertToolChoice(req.ToolChoice) - } - if req.User != "" { - respReq["user"] = req.User - } - if req.ParallelToolCalls != nil { - respReq["parallel_tool_calls"] = *req.ParallelToolCalls - } - - result, err := json.Marshal(respReq) - if err != nil { - return nil, fmt.Errorf("marshal responses request: %w", err) - } - return result, nil -} - -func c2rExtractInstructions(messages []dto.Message) string { - var parts []string - for _, msg := range messages { - if msg.Role == "system" || msg.Role == "developer" { - if text, ok := msg.Content.(string); ok && text != "" { - parts = append(parts, text) - } - } - } - if len(parts) == 0 { - return "" - } - instructions := parts[0] - for i := 1; i < len(parts); i++ { - instructions += "\n\n" + parts[i] - } - return instructions -} - -func c2rConvertMessagesToInput(messages []dto.Message) []any { - var input []any - for _, msg := range messages { - if msg.Role == "system" || msg.Role == "developer" { - continue - } - switch msg.Role { - case "user": - input = append(input, c2rMakeMessageItem("user", msg.Content)) - case "assistant": - if len(msg.ToolCalls) > 0 { - if msg.Content != nil { - if text, ok := msg.Content.(string); ok && text != "" { - input = append(input, c2rMakeMessageItem("assistant", text)) - } - } - for _, tc := range msg.ToolCalls { - input = append(input, map[string]any{ - "type": "function_call", "call_id": tc.ID, "name": tc.Function.Name, "arguments": tc.Function.Arguments, - }) - } - } else { - input = append(input, c2rMakeMessageItem("assistant", msg.Content)) - } - case "tool": - input = append(input, map[string]any{ - "type": "function_call_output", "call_id": msg.ToolCallID, "output": c2rContentToString(msg.Content), - }) - } - } - if len(input) == 0 { - return []any{} - } - return input -} - -func c2rMakeMessageItem(role string, content any) map[string]any { - if content == nil { - return map[string]any{"type": "message", "role": role, "content": []any{}} - } - switch v := content.(type) { - case string: - textType := "input_text" - if role == "assistant" { - textType = "output_text" - } - return map[string]any{"type": "message", "role": role, "content": []any{map[string]any{"type": textType, "text": v}}} - case []any: - parts := make([]any, 0, len(v)) - for _, item := range v { - part, ok := item.(map[string]any) - if !ok { - continue - } - if converted := c2rConvertContentPart(part, role); converted != nil { - parts = append(parts, converted) - } - } - return map[string]any{"type": "message", "role": role, "content": parts} - default: - b, err := json.Marshal(content) - if err != nil { - return map[string]any{"type": "message", "role": role, "content": []any{}} - } - var parts []any - if err := json.Unmarshal(b, &parts); err == nil { - converted := make([]any, 0, len(parts)) - for _, item := range parts { - part, ok := item.(map[string]any) - if !ok { - continue - } - if c := c2rConvertContentPart(part, role); c != nil { - converted = append(converted, c) - } - } - return map[string]any{"type": "message", "role": role, "content": converted} - } - return map[string]any{"type": "message", "role": role, "content": []any{}} - } -} - -func c2rConvertContentPart(part map[string]any, role string) map[string]any { - partType, _ := part["type"].(string) - switch partType { - case "text": - text, _ := part["text"].(string) - textType := "input_text" - if role == "assistant" { - textType = "output_text" - } - return map[string]any{"type": textType, "text": text} - case "image_url": - imgURL, _ := part["image_url"].(map[string]any) - if imgURL != nil { - result := map[string]any{"type": "input_image"} - if url, ok := imgURL["url"].(string); ok { - result["image_url"] = url - } - if detail, ok := imgURL["detail"].(string); ok { - result["detail"] = detail - } - return result - } - case "input_audio": - data, _ := part["data"].(string) - format, _ := part["format"].(string) - return map[string]any{"type": "input_audio", "data": data, "format": format} - case "file": - result := map[string]any{"type": "input_file"} - if fileData, ok := part["file_data"].(string); ok { - result["file_data"] = fileData - } - if filename, ok := part["filename"].(string); ok { - result["filename"] = filename - } - return result - } - return nil -} - -func c2rConvertTools(tools []dto.Tool) []any { - result := make([]any, 0, len(tools)) - for _, tool := range tools { - if tool.Type == "function" { - result = append(result, map[string]any{ - "type": "function", "name": tool.Function.Name, "description": tool.Function.Description, "parameters": tool.Function.Parameters, - }) - } - } - return result -} - -func c2rConvertToolChoice(toolChoice any) any { - b, err := json.Marshal(toolChoice) - if err != nil { - return toolChoice - } - var tc map[string]any - if err := json.Unmarshal(b, &tc); err != nil { - return toolChoice - } - if tc["type"] == "function" { - if fn, ok := tc["function"].(map[string]any); ok { - if name, ok := fn["name"].(string); ok { - return map[string]any{"type": "function", "name": name} - } - } - } - return tc -} - -func c2rContentToString(content any) string { - if content == nil { - return "" - } - if s, ok := content.(string); ok { - return s - } - b, _ := json.Marshal(content) - return string(b) -} - -func c2rGetMaxTokens(req dto.GeneralOpenAIRequest) int { - max := 0 - if req.MaxTokens != nil && *req.MaxTokens > 0 { - max = *req.MaxTokens - } - if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens > max { - max = *req.MaxCompletionTokens - } - return max -} - -// c2rBuildTextFormat 将 chat 的 response_format 转换为 Responses 的 text 配置。 -// chat 的 json_schema 为嵌套 {type,json_schema:{name,schema,strict}}, -// Responses 的 format 为扁平 {type,name,schema,strict}——需解包提升,不能原样塞入。 -// 其余类型(json_object)两侧同形;无法识别时返回 nil 不映射。 -func c2rBuildTextFormat(rf *dto.ResponseFormat) map[string]any { - if rf == nil { - return nil - } - switch rf.Type { - case "json_object": - return map[string]any{"format": map[string]any{"type": "json_object"}} - case "json_schema": - format := map[string]any{"type": "json_schema"} - if js, ok := rf.JSONSchema.(map[string]any); ok { - for _, k := range []string{"name", "schema", "strict"} { - if v, ok := js[k]; ok { - format[k] = v - } - } - } - return map[string]any{"format": format} - default: - return nil - } -} - // ===== Responses → Chat 响应转换 ===== // ResponsesResponseToChatCompletions 将 Responses API 非流式响应转换为 Chat Completions 响应 @@ -1646,3 +1416,6 @@ func HandleResponsesNonStreamToChat(ctx context.Context, resp *http.Response, in writer.Write(resultBody) return usage, nil } + +// intPtr 供测试构造 *int 参数(c2o 边界值测试用) +func intPtr(v int) *int { return &v } diff --git a/relay/channel/openai/converter_test.go b/relay/channel/openai/converter_test.go index 699976f..793d291 100644 --- a/relay/channel/openai/converter_test.go +++ b/relay/channel/openai/converter_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "io" + "strings" "testing" "github.com/qianfree/team-api/relay/common" @@ -95,7 +96,7 @@ func TestG2oConvertThinkingConfig(t *testing.T) { {intPtr(20000), "high"}, } for _, tt := range tests { - got := g2oConvertThinkingConfig(&dto.GeminiThinkingConfig{ThoughtBudget: tt.budget}) + got := g2oConvertThinkingConfig(&dto.GeminiThinkingConfig{ThinkingBudget: tt.budget}) if got != tt.want { t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want) } @@ -156,44 +157,6 @@ func TestG2oMapRole(t *testing.T) { } } -func TestC2rContentToString(t *testing.T) { - if got := c2rContentToString(nil); got != "" { - t.Errorf("nil => %q, want empty", got) - } - if got := c2rContentToString("hello"); got != "hello" { - t.Errorf("string => %q", got) - } - // 非字符串 -> JSON 编码 - got := c2rContentToString([]any{map[string]any{"type": "text", "text": "x"}}) - var back []any - if err := json.Unmarshal([]byte(got), &back); err != nil { - t.Errorf("non-string content should be JSON-encoded, got %q (%v)", got, err) - } -} - -func TestC2rGetMaxTokens(t *testing.T) { - tests := []struct { - name string - maxTok *int - maxComp *int - want int - }{ - {"both nil", nil, nil, 0}, - {"max_tokens only", intPtr(100), nil, 100}, - {"max_completion larger wins", intPtr(100), intPtr(200), 200}, - {"max_completion smaller keeps max_tokens", intPtr(300), intPtr(50), 300}, - {"zero max_tokens ignored", intPtr(0), nil, 0}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := dto.GeneralOpenAIRequest{MaxTokens: tt.maxTok, MaxCompletionTokens: tt.maxComp} - if got := c2rGetMaxTokens(req); got != tt.want { - t.Errorf("got %d, want %d", got, tt.want) - } - }) - } -} - func TestConvertClaudeUserMessage_StringContent(t *testing.T) { msgs := convertClaudeUserMessage(dto.ClaudeMessage{Role: "user", Content: "hi there"}) if len(msgs) != 1 { @@ -260,34 +223,6 @@ func TestConvertResponsesToOpenAI_PenaltyPassthrough(t *testing.T) { } } -// TestConvertOpenAIToResponses_PenaltyDropped chat 入站转 Responses 出站时 -// 丢弃官方不支持的 presence/frequency penalty(透传会被严格上游拒绝), -// 保留官方参数 prompt_cache_key。 -func TestConvertOpenAIToResponses_PenaltyDropped(t *testing.T) { - info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"frequency_penalty":0.5,"presence_penalty":0.2,"prompt_cache_key":"abc"}`) - out, err := ConvertOpenAIToResponses(body, info) - if err != nil { - t.Fatalf("ConvertOpenAIToResponses: %v", err) - } - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("bad json: %v\n%s", err, out) - } - if _, ok := m["frequency_penalty"]; ok { - t.Error("frequency_penalty should be dropped (not official Responses API)") - } - if _, ok := m["presence_penalty"]; ok { - t.Error("presence_penalty should be dropped (not official Responses API)") - } - if m["prompt_cache_key"] != "abc" { - t.Errorf("prompt_cache_key = %v, want abc", m["prompt_cache_key"]) - } - if _, ok := m["input"]; !ok { - t.Error("input should be present") - } -} - // TestConvertResponsesToOpenAI_PreviousResponseIDRejected 有状态请求(previous_response_id) // 落在 chat-only 渠道时必须快速失败:降级转换会静默丢失全部会话上下文。 // 错误须携带哨兵以供 relay_handler 识别并驱动调度 FSM 换渠道。 @@ -519,67 +454,74 @@ func TestConvertResponsesToOpenAI_FunctionCallHistory(t *testing.T) { } } -// TestConvertOpenAIToResponses_TextFormatUnpack chat response_format 转 Responses -// text.format:json_schema 需解包为扁平结构(不能原样塞入嵌套形状)。 -func TestConvertOpenAIToResponses_TextFormatUnpack(t *testing.T) { - t.Run("json_schema", func(t *testing.T) { - info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],` + - `"response_format":{"type":"json_schema","json_schema":{"name":"out","schema":{"type":"object"},"strict":true}}}`) - out, err := ConvertOpenAIToResponses(body, info) - if err != nil { - t.Fatalf("ConvertOpenAIToResponses: %v", err) - } - var m map[string]any - if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("bad json: %v\n%s", err, out) - } - text, _ := m["text"].(map[string]any) - format, _ := text["format"].(map[string]any) - if format["type"] != "json_schema" { - t.Fatalf("format = %v", format) - } - if format["name"] != "out" { - t.Errorf("format.name = %v, want out", format["name"]) - } - if _, nested := format["json_schema"]; nested { - t.Error("format should be flat(json_schema 嵌套必须解包)") - } - if s, _ := format["schema"].(map[string]any); s == nil { - t.Errorf("format.schema = %v", format["schema"]) - } - }) - t.Run("json_object", func(t *testing.T) { - info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) - out, err := ConvertOpenAIToResponses(body, info) - if err != nil { - t.Fatalf("ConvertOpenAIToResponses: %v", err) - } - var m map[string]any - _ = json.Unmarshal(out, &m) - text, _ := m["text"].(map[string]any) - format, _ := text["format"].(map[string]any) - if format["type"] != "json_object" { - t.Errorf("format = %v", format) - } - }) +// TestConvertResponsesToOpenAI_ReasoningPassedBack 思考内容回传(与 relaykit 侧镜像实现同口径): +// DeepSeek 等推理上游的 thinking 模式要求多轮历史携带 reasoning_content,丢弃会 400。 +func TestConvertResponsesToOpenAI_ReasoningPassedBack(t *testing.T) { + info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} + body := []byte(`{"model":"deepseek-v4-pro","input":[` + + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},` + + `{"type":"reasoning","content":[{"type":"reasoning_text","text":"思考X"}]},` + + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"答"}]},` + + `{"type":"reasoning","summary":[{"type":"summary_text","text":"思考Y"}]},` + + `{"type":"function_call","call_id":"call_c","name":"shell","arguments":"{}"},` + + `{"type":"function_call_output","call_id":"call_c","output":"ok"}` + + `]}`) + out, err := ConvertResponsesToOpenAI(body, info) + if err != nil { + t.Fatalf("ConvertResponsesToOpenAI: %v", err) + } + raw, _ := io.ReadAll(out) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + msgs, _ := m["messages"].([]any) + if len(msgs) != 4 { + t.Fatalf("messages = %v, want 4 (user + assistant + assistant[call] + tool)", msgs) + } + if got := msgs[1].(map[string]any)["reasoning_content"]; got != "思考X" { + t.Errorf("messages[1].reasoning_content = %v, want 思考X", got) + } + if got := msgs[2].(map[string]any)["reasoning_content"]; got != "思考Y" { + t.Errorf("messages[2].reasoning_content = %v, want 思考Y(挂到 tool_calls 聚合消息)", got) + } + if _, ok := msgs[0].(map[string]any)["reasoning_content"]; ok { + t.Error("user 消息不应携带 reasoning_content") + } } -// TestConvertOpenAIToResponses_StoreFalse 桥接方向显式 store:false: -// chat 客户端无法经 previous_response_id 引用响应,无需上游存储。 -func TestConvertOpenAIToResponses_StoreFalse(t *testing.T) { - info := &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} - body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) - out, err := ConvertOpenAIToResponses(body, info) +// TestStripWebSearchOptionsForClaudeModels chat 终端出口剥离 web_search_options: +// 该参数是 Responses→OpenAI→Claude 链的中间载体,必须完整通过转换层; +// 但 chat 上游实际服务 claude 系模型时(聚合器场景),聚合器会把它映射为无法在 +// chat 协议表达的原生搜索工具,模型发起的搜索被吞成空响应——只在此终端剥离。 +func TestStripWebSearchOptionsForClaudeModels(t *testing.T) { + mkInfo := func(model string) *common.RelayInfo { + return &common.RelayInfo{ + OriginModelName: model, + ChannelMeta: &common.ChannelMeta{UpstreamModelName: model}, + } + } + body := `{"model":"claude-sonnet-5","messages":[],"web_search_options":{}}` + + // claude 系模型:剥离 + out, err := io.ReadAll(stripWebSearchOptionsForClaudeModels(strings.NewReader(body), mkInfo("claude-sonnet-5"))) if err != nil { - t.Fatalf("ConvertOpenAIToResponses: %v", err) + t.Fatal(err) } var m map[string]any if err := json.Unmarshal(out, &m); err != nil { - t.Fatalf("bad json: %v\n%s", err, out) + t.Fatalf("剥离后应仍是合法 JSON: %v", err) } - if v, ok := m["store"]; !ok || v != false { - t.Errorf("store = %v(%T), want explicit false", m["store"], m["store"]) + if _, ok := m["web_search_options"]; ok { + t.Error("claude 系模型的 chat 终端请求不应携带 web_search_options") + } + + // 非 claude 模型:保留(OpenAI 搜索系模型的合法参数) + out2, err := io.ReadAll(stripWebSearchOptionsForClaudeModels(strings.NewReader(body), mkInfo("gpt-4o-search-preview"))) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out2), "web_search_options") { + t.Error("非 claude 模型应保留 web_search_options") } } diff --git a/relay/channel/openai/gemini_response.go b/relay/channel/openai/gemini_response.go index f5b064a..a6e2b90 100644 --- a/relay/channel/openai/gemini_response.go +++ b/relay/channel/openai/gemini_response.go @@ -1,21 +1,23 @@ package openai import ( - "bufio" "context" "encoding/json" "fmt" "io" "net/http" - "strings" - "time" "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" ) +// Gemini 入站(客户端说 Gemini、上游说 OpenAI)的响应侧接线。 +// 协议转换本身在 relaykit(internal/oai_gemini 的 OpenAIToGemini 转换器); +// 本文件只保留宿主职责:HTTP 状态码处理、错误按 Gemini 原生格式透传、 +// 响应写出与计费用量提取。 + // handleGeminiInboundNonStream 将 OpenAI 非流式响应转换为 Gemini 格式 func handleGeminiInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { defer resp.Body.Close() @@ -32,305 +34,53 @@ func handleGeminiInboundNonStream(ctx context.Context, resp *http.Response, info return &common.Usage{}, upstreamErr } - var openaiResp dto.ChatCompletionResponse - if err := json.Unmarshal(body, &openaiResp); err != nil { - return nil, fmt.Errorf("invalid response body: %w", err) + // relaykit 响应转换(唯一路径,hard-fail) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("openai adaptor: no relaykit converter for gemini client response", nil) + } + if convErr != nil { + return nil, fmt.Errorf("invalid response body: %w", convErr) } - - geminiResp := openAIToGeminiResponse(&openaiResp, info) - - respBody, _ := json.Marshal(geminiResp) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(respBody) + _, _ = writer.Write(convertedBody) - usage := &common.Usage{ - PromptTokens: openaiResp.Usage.PromptTokens, - CompletionTokens: openaiResp.Usage.CompletionTokens, - TotalTokens: openaiResp.Usage.TotalTokens, - PromptTokensDetails: common.DtoTokenDetailsToCommon(openaiResp.Usage.PromptTokensDetails), - CompletionTokenDetails: common.DtoTokenDetailsToCommon(openaiResp.Usage.CompletionTokenDetails), - CacheIncludedInPrompt: true, + // 计费用量取上游 OpenAI 原始口径(prompt 含缓存,cached 为其子集) + usage := &common.Usage{CacheIncludedInPrompt: true} + var openaiResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &openaiResp); err == nil { + usage.PromptTokens = openaiResp.Usage.PromptTokens + usage.CompletionTokens = openaiResp.Usage.CompletionTokens + usage.TotalTokens = openaiResp.Usage.TotalTokens + usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(openaiResp.Usage.PromptTokensDetails) + usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(openaiResp.Usage.CompletionTokenDetails) } return usage, nil } // handleGeminiInboundStream 将 OpenAI SSE 流转换为 Gemini SSE 流 func handleGeminiInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - resp.Body.Close() writeOpenAIErrorAsGemini(writer, body, resp.StatusCode) upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) upstreamErr.ResponseWritten = true return &common.Usage{}, upstreamErr } - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - var ( - usage common.Usage - finishReason string - transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 - ) - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - helper.ApplyInterruptedUsageFallback(info, &usage, transferredTextLen) - return &usage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" { - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - - data, _ := helper.ExtractSSEData(line) - - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } - - if data == "[DONE]" { - break - } - - var streamResp dto.ChatCompletionStreamResponse - if err := json.Unmarshal([]byte(data), &streamResp); err != nil { - continue - } - - // 收集 usage(通常在最后一个 chunk) - if streamResp.Usage != nil { - usage.PromptTokens = streamResp.Usage.PromptTokens - usage.CompletionTokens = streamResp.Usage.CompletionTokens - usage.TotalTokens = streamResp.Usage.TotalTokens - usage.PromptTokensDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.PromptTokensDetails) - usage.CompletionTokenDetails = common.DtoTokenDetailsToCommon(streamResp.Usage.CompletionTokenDetails) - usage.CacheIncludedInPrompt = true - } - - // 构造 Gemini 响应 chunk - geminiChunk := dto.GeminiChatResponse{} - - for _, choice := range streamResp.Choices { - if choice.FinishReason != nil && *choice.FinishReason != "" { - finishReason = *choice.FinishReason - } - - // 累计已转发文本长度,供流中断输出估算 - if text, ok := choice.Delta.Content.(string); ok { - transferredTextLen += len(text) - } - if choice.Delta.ReasoningContent != nil { - transferredTextLen += len(*choice.Delta.ReasoningContent) - } - - parts := buildGeminiPartsFromDelta(&choice.Delta) - if len(parts) == 0 && choice.FinishReason == nil { - continue - } - - candidate := dto.GeminiCandidate{ - Index: choice.Index, - Content: &dto.GeminiContent{ - Role: "model", - Parts: parts, - }, - } - - geminiChunk.Candidates = append(geminiChunk.Candidates, candidate) - } - - if len(geminiChunk.Candidates) > 0 { - // 传指针而非结构体值:值传进 any 会额外堆分配一份拷贝用于装箱 - _ = helper.WriteSSEDataJSON(writer, &geminiChunk) - } + // relaykit 流式转换(唯一路径,hard-fail):桥接层负责 Gemini data 帧写出与 usage 捕获 + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决:err 非空即流以错误/客户端中断结束 + //(已带 ResponseWritten,上层只记账与上报调度,不重写响应体、不换渠道重试), + // usage 中断兜底亦已在桥接层完成,此处原样上抛即可。 + return usage, err } - - // 发送包含 finishReason 和 usageMetadata 的最终 chunk - finalChunk := dto.GeminiChatResponse{} - reason := common.OpenAIFinishReasonToGemini(finishReason) - finalChunk.Candidates = []dto.GeminiCandidate{{ - Content: &dto.GeminiContent{ - Role: "model", - }, - FinishReason: reason, - }} - if usage.PromptTokens > 0 || usage.CompletionTokens > 0 { - // Gemini 语义:CandidatesTokenCount 不含 thoughts,OpenAI CompletionTokens 已含 reasoning - // 需要扣减 reasoning 避免双计(Gemini 客户端按 total = prompt + candidates + thoughts 汇总) - candidatesTokens := usage.CompletionTokens - if usage.CompletionTokenDetails != nil && usage.CompletionTokenDetails.ReasoningTokens > 0 { - candidatesTokens -= usage.CompletionTokenDetails.ReasoningTokens - if candidatesTokens < 0 { - candidatesTokens = 0 - } - } - - finalChunk.UsageMetadata = &dto.GeminiUsageMetadata{ - PromptTokenCount: usage.PromptTokens, - CandidatesTokenCount: candidatesTokens, - TotalTokenCount: usage.TotalTokens, - } - if usage.PromptTokensDetails != nil { - finalChunk.UsageMetadata.CachedContentTokenCount = usage.PromptTokensDetails.CachedTokens - } - if usage.CompletionTokenDetails != nil { - finalChunk.UsageMetadata.ThoughtsTokenCount = usage.CompletionTokenDetails.ReasoningTokens - } - } - _ = helper.WriteSSEDataJSON(writer, &finalChunk) - - _ = helper.WriteSSEData(writer, "[DONE]") - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - - if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return &usage, fmt.Errorf("stream scanner error: %w", err) - } - - return &usage, nil + return nil, constant.NewChannelError("openai adaptor: relaykit stream converter unavailable for gemini client", nil) } -// openAIToGeminiResponse 将 OpenAI ChatCompletion 响应转换为 Gemini Chat 响应 -func openAIToGeminiResponse(openaiResp *dto.ChatCompletionResponse, info *common.RelayInfo) dto.GeminiChatResponse { - resp := dto.GeminiChatResponse{} - - if len(openaiResp.Choices) == 0 { - return resp - } - - choice := openaiResp.Choices[0] - parts := buildGeminiPartsFromMessage(&choice.Message) - - candidate := dto.GeminiCandidate{ - Index: choice.Index, - Content: &dto.GeminiContent{ - Role: "model", - Parts: parts, - }, - FinishReason: common.OpenAIFinishReasonToGemini(choice.FinishReason), - } - - resp.Candidates = []dto.GeminiCandidate{candidate} - - // Gemini 语义:CandidatesTokenCount 不含 thoughts,OpenAI CompletionTokens 已含 reasoning - // 需要扣减 reasoning 避免双计(Gemini 客户端按 total = prompt + candidates + thoughts 汇总) - candidatesTokens := openaiResp.Usage.CompletionTokens - if openaiResp.Usage.CompletionTokenDetails != nil && openaiResp.Usage.CompletionTokenDetails.ReasoningTokens > 0 { - candidatesTokens -= openaiResp.Usage.CompletionTokenDetails.ReasoningTokens - if candidatesTokens < 0 { - candidatesTokens = 0 - } - } - - resp.UsageMetadata = &dto.GeminiUsageMetadata{ - PromptTokenCount: openaiResp.Usage.PromptTokens, - CandidatesTokenCount: candidatesTokens, - TotalTokenCount: openaiResp.Usage.TotalTokens, - } - if openaiResp.Usage.PromptTokensDetails != nil { - resp.UsageMetadata.CachedContentTokenCount = openaiResp.Usage.PromptTokensDetails.CachedTokens - } - if openaiResp.Usage.CompletionTokenDetails != nil { - resp.UsageMetadata.ThoughtsTokenCount = openaiResp.Usage.CompletionTokenDetails.ReasoningTokens - } - - return resp -} - -// buildGeminiPartsFromMessage 将 OpenAI Message 转换为 Gemini Parts -func buildGeminiPartsFromMessage(msg *dto.Message) []dto.GeminiPart { - var parts []dto.GeminiPart - - // thinking 内容 → thought part - if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { - parts = append(parts, dto.GeminiPart{ - Text: *msg.ReasoningContent, - Thought: boolPtr(true), - }) - } - - // 文本内容 - if text, ok := msg.Content.(string); ok && text != "" { - parts = append(parts, dto.GeminiPart{Text: text}) - } - - // 工具调用 → functionCall parts - for _, tc := range msg.ToolCalls { - var args any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{} - } - parts = append(parts, dto.GeminiPart{ - FunctionCall: &dto.GeminiFunctionCall{ - ID: tc.ID, - FunctionName: tc.Function.Name, - Arguments: args, - }, - }) - } - - if len(parts) == 0 { - parts = append(parts, dto.GeminiPart{Text: ""}) - } - - return parts -} - -// buildGeminiPartsFromDelta 将 OpenAI 流式 Delta 转换为 Gemini Parts -func buildGeminiPartsFromDelta(delta *dto.Message) []dto.GeminiPart { - var parts []dto.GeminiPart - - // thinking 内容 - if delta.ReasoningContent != nil && *delta.ReasoningContent != "" { - parts = append(parts, dto.GeminiPart{ - Text: *delta.ReasoningContent, - Thought: boolPtr(true), - }) - } - - // 文本内容 - if text, ok := delta.Content.(string); ok && text != "" { - parts = append(parts, dto.GeminiPart{Text: text}) - } - - // 工具调用 - for _, tc := range delta.ToolCalls { - var args any - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{} - } - } - parts = append(parts, dto.GeminiPart{ - FunctionCall: &dto.GeminiFunctionCall{ - ID: tc.ID, - FunctionName: tc.Function.Name, - Arguments: args, - }, - }) - } - - return parts -} - -// writeOpenAIErrorAsGemini 将上游 OpenAI 错误转换为 Gemini RPC Status 格式写入响应 func writeOpenAIErrorAsGemini(writer http.ResponseWriter, body []byte, defaultStatusCode int) { var openaiErr struct { Error struct { diff --git a/relay/channel/openai/pooled_sse_stream_test.go b/relay/channel/openai/pooled_sse_stream_test.go index 36d02bc..951a8d2 100644 --- a/relay/channel/openai/pooled_sse_stream_test.go +++ b/relay/channel/openai/pooled_sse_stream_test.go @@ -97,13 +97,11 @@ func TestHandleGeminiInboundStream_PooledFramesValid(t *testing.T) { } var text strings.Builder - sawDone := false for i, f := range frames { if f == "[DONE]" { - sawDone = true - if i != len(frames)-1 { - t.Errorf("[DONE] 不在最后一帧(位置 %d/%d)", i, len(frames)-1) - } + // 真实 Gemini API 无 [DONE] 哨兵;官方 SDK 对每个 data 帧做 JSON.parse, + // 写 [DONE] 会直接抛 SyntaxError 导致客户端报错 + t.Errorf("Gemini 客户端不得收到 [DONE](第 %d 帧)", i) continue } var chunk dto.GeminiChatResponse @@ -119,8 +117,10 @@ func TestHandleGeminiInboundStream_PooledFramesValid(t *testing.T) { } } } - if !sawDone { - t.Error("缺少 [DONE] 终止帧") + // 收尾 chunk 的 content 不得带 "parts": null(官方 SDK isValidContent 只判 undefined + // 后直接读 .length,null 会崩溃) + if strings.Contains(rec.body.String(), `"parts":null`) { + t.Error(`响应中出现 "parts":null,Gemini 官方 SDK 会崩溃`) } if got := text.String(); got != "你好世界" { t.Errorf("转发文本 = %q, want %q", got, "你好世界") diff --git a/relay/channel/openai/responses.go b/relay/channel/openai/responses.go index aa35e1c..986b67f 100644 --- a/relay/channel/openai/responses.go +++ b/relay/channel/openai/responses.go @@ -2,7 +2,6 @@ package openai import ( "bufio" - "bytes" "context" "encoding/json" "errors" @@ -18,11 +17,14 @@ import ( "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" ) // ========== 响应转换:Chat Completions → Responses ========== -// handleResponsesInboundNonStream 将 Chat Completions 非流式响应转换为 Responses 格式 +// handleResponsesInboundNonStream 将 Chat Completions 非流式响应转换为 Responses 格式。 +// 转换由 relaykit 转换器完成(openai → openai_responses 方向),本函数只保留非 200 +// 错误透传与桥接调用(hard-fail);计费用量按上游原始 chat 体口径提取(prompt 已含缓存)。 func (a *Adaptor) handleResponsesInboundNonStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { defer resp.Body.Close() @@ -42,29 +44,24 @@ func (a *Adaptor) handleResponsesInboundNonStream(ctx context.Context, resp *htt return nil, constant.NewUpstreamErrorFromResponse(resp, body) } - // 解析 Chat Completions 响应 - var chatResp dto.ChatCompletionResponse - if err := json.Unmarshal(body, &chatResp); err != nil { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(resp.StatusCode) - _, _ = writer.Write(body) - return &common.Usage{}, nil + // relaykit 响应转换(唯一路径,hard-fail:解析/转换失败即向上返回错误) + convertedBody, _, handled, convErr := relaykit_bridge.TryConvertResponseViaRelaykit(ctx, info, body) + if !handled { + return nil, constant.NewChannelError("openai adaptor: no relaykit converter for responses client response", nil) } - - // 转换为 Responses 格式 - responsesResp := chatCompletionToResponsesResponse(&chatResp, info) - responsesBody, err := json.Marshal(responsesResp) - if err != nil { - writer.Header().Set("Content-Type", "application/json") - writer.WriteHeader(resp.StatusCode) - _, _ = writer.Write(body) - return &common.Usage{}, nil + if convErr != nil { + return nil, constant.NewUpstreamError(resp.StatusCode, "invalid response body", convErr).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) } writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - _, _ = writer.Write(responsesBody) + _, _ = writer.Write(convertedBody) + // 计费用量按上游原始 chat 体(OpenAI 口径 prompt 含缓存,置 CacheIncludedInPrompt 按明细扣减) + var chatResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return &common.Usage{CacheIncludedInPrompt: true}, nil + } return &common.Usage{ PromptTokens: chatResp.Usage.PromptTokens, CompletionTokens: chatResp.Usage.CompletionTokens, @@ -73,6 +70,39 @@ func (a *Adaptor) handleResponsesInboundNonStream(ctx context.Context, resp *htt }, nil } +// handleResponsesInboundStream 将 Chat Completions 流式响应转换为 Responses 格式的 SSE。 +// 转换由 relaykit 流式转换器完成(openai → openai_responses 方向),本函数只保留 +// 非 200 错误透传与桥接调用(hard-fail:桥接层未接管即注册缺失,显式报错)。 +func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { + defer resp.Body.Close() + + // 非 200:上游在 SSE 开始前返回错误(非 SSE 体),透传并返回上游错误驱动重试 + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + if isUpstreamOpenAIError(body) { + writeUpstreamErrorResponse(writer, resp.StatusCode, body) + upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) + upstreamErr.ResponseWritten = true + return &common.Usage{}, upstreamErr + } + return nil, constant.NewUpstreamErrorFromResponse(resp, body) + } + + if usage, ok, err := relaykit_bridge.TryConvertStreamViaRelaykit(ctx, info, resp.Body, writer); ok { + // 桥接层已写出 SSE 并完成收尾裁决与 usage 捕获,err 非空即流以错误/中断结束 + return usage, err + } + return nil, constant.NewChannelError("openai adaptor: relaykit stream converter unavailable for responses client", nil) +} + +// EmitResponsesSSE 发送一个 Responses API 格式的 SSE 事件(导出供 claude 等适配器的 Responses 桥接复用)。 +// 池化缓冲,整帧单次写出,零分配;序列化失败时跳过该事件并记日志(空 data 帧会让客户端解析失败)。 +func EmitResponsesSSE(w http.ResponseWriter, eventType string, data any) { + if err := helper.WriteSSEEventJSON(w, eventType, data); errors.Is(err, helper.ErrSSEPayloadMarshal) { + g.Log().Errorf(context.Background(), "[ResponsesSSE] marshal %s event failed, event skipped: %v", eventType, err) + } +} + // responsesRequestEcho 合成 Responses 响应时需回显(echo)的请求参数 type responsesRequestEcho struct { temperature *float64 @@ -105,566 +135,6 @@ func extractResponsesRequestEcho(info *common.RelayInfo) responsesRequestEcho { return echo } -// chatCompletionToResponsesResponse 将 Chat Completions 响应转换为 Responses API 响应 -func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, info *common.RelayInfo) *dto.OpenAIResponsesResponse { - modelName := info.OriginModelName - echo := extractResponsesRequestEcho(info) - - // 构建 output - output := make([]dto.ResponsesOutput, 0) - for _, choice := range chatResp.Choices { - // 文本内容 - content := make([]dto.ResponsesOutputContent, 0) - if choice.Message.Content != nil { - var textContent string - switch v := choice.Message.Content.(type) { - case string: - textContent = v - default: - b, _ := json.Marshal(v) - textContent = string(b) - } - if textContent != "" { - content = append(content, dto.ResponsesOutputContent{ - Type: "output_text", - Text: textContent, - Annotations: []dto.ResponsesAnnotation{}, - }) - } - } - - msgOutput := dto.ResponsesOutput{ - Type: "message", - ID: fmt.Sprintf("msg_%s", chatResp.ID), - Status: "completed", - Role: "assistant", - Content: content, - } - output = append(output, msgOutput) - - // 工具调用 - for _, tc := range choice.Message.ToolCalls { - output = append(output, dto.ResponsesOutput{ - Type: "function_call", - ID: tc.ID, - CallID: tc.ID, - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - Status: "completed", - }) - } - } - - return &dto.OpenAIResponsesResponse{ - ID: fmt.Sprintf("resp_%s", chatResp.ID), - Object: "response", - CreatedAt: int(chatResp.Created), - CompletedAt: int(chatResp.Created) + 1, - Status: json.RawMessage(`"completed"`), - Error: nil, - IncompleteDetails: nil, - Instructions: echo.instructions, - MaxOutputTokens: echo.maxOutputTokens, - Model: modelName, - Output: output, - ParallelToolCalls: true, - PreviousResponseID: nil, - Reasoning: &dto.ResponsesReasoning{Effort: nil, Summary: nil}, - // store:false 是真实语义:合成响应不落上游存储,客户端不可经 GET /v1/responses/{id} retrieve - Store: false, - Temperature: echo.temperature, - Text: &dto.ResponsesText{Format: dto.ResponsesTextFormat{Type: "text"}}, - ToolChoice: "auto", - Tools: make([]any, 0), - TopP: echo.topP, - Truncation: "disabled", - User: nil, - Metadata: make(map[string]any), - Usage: &dto.ResponsesUsage{ - InputTokens: chatResp.Usage.PromptTokens, - OutputTokens: chatResp.Usage.CompletionTokens, - TotalTokens: chatResp.Usage.TotalTokens, - InputTokensDetails: &dto.InputTokenDetails{ - CachedTokens: chatResp.Usage.PromptTokensDetails.CachedTokens, - CacheWriteTokens: chatResp.Usage.PromptTokensDetails.CacheWriteTokens, - AudioTokens: chatResp.Usage.PromptTokensDetails.AudioTokens, - }, - OutputTokenDetails: &dto.OutputTokenDetails{ - ReasoningTokens: chatResp.Usage.CompletionTokenDetails.ReasoningTokens, - AcceptedPredictionTokens: chatResp.Usage.CompletionTokenDetails.AcceptedPredictionTokens, - RejectedPredictionTokens: chatResp.Usage.CompletionTokenDetails.RejectedPredictionTokens, - }, - }, - } -} - -// ========== 流式响应转换:Chat Completions SSE → Responses SSE ========== - -// handleResponsesInboundStream 将 Chat Completions 流式响应转换为 Responses 格式的 SSE -func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) { - defer resp.Body.Close() - - // 非 200:上游在 SSE 开始前返回错误(非 SSE 体),透传并返回上游错误驱动重试 - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - if isUpstreamOpenAIError(body) { - writeUpstreamErrorResponse(writer, resp.StatusCode, body) - upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) - upstreamErr.ResponseWritten = true - return &common.Usage{}, upstreamErr - } - return nil, constant.NewUpstreamErrorFromResponse(resp, body) - } - - helper.SetEventStreamHeaders(writer) - writer = helper.NewSafeWriter(writer) - defer helper.PingTicker(writer, 15*time.Second)() - - scanner := bufio.NewScanner(resp.Body) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - modelName := info.OriginModelName - respID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) - msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) - createdAt := int(time.Now().Unix()) - - var usage common.Usage - var contentBuilder strings.Builder - sentCreated := false - sentTextDone := false - parsedChunks := 0 - sawChoices := false - outputIndex := 0 - contentIndex := 0 - toolCallIndexByID := make(map[string]int) - toolCallArgsByID := make(map[string]string) - toolCallNameByID := make(map[string]string) - // 通过 index 追踪 tool call ID(OpenAI 流式中后续 chunk 的 ID 为空,只有 index) - toolCallIDByIndex := make(map[int]string) - - for scanner.Scan() { - select { - case <-ctx.Done(): - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) - // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 - helper.ApplyInterruptedUsageFallback(info, &usage, contentBuilder.Len()) - return &usage, common.ErrStreamInterrupted - default: - } - - line := scanner.Text() - if line == "" { - continue - } - if strings.HasPrefix(line, "event:") { - continue - } - if !strings.HasPrefix(line, "data:") { - continue - } - - data, _ := helper.ExtractSSEData(line) - - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } - if data == "[DONE]" { - break - } - - // 检测 SSE 流中内嵌的上游错误对象:部分聚合商出错时返回 HTTP 200 + SSE, - // 错误信息夹在 data 行里({"error":{...}})。不识别会被当作解析失败静默丢弃, - // 最终合成空的 response.completed(客户端表现为"成功但无内容")。 - if errBody, ok := extractStreamEmbeddedError([]byte(data)); ok { - err := fmt.Errorf("upstream embedded error in SSE stream: %.500s", string(errBody)) - g.Log().Warningf(ctx, "[OpenAI.handleResponsesInboundStream] %v", err) - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - if !sentCreated { - // 尚未向客户端发送任何事件:透传上游错误体(与 SSE 开始前的非 200 分支行为一致) - writeUpstreamErrorResponse(writer, resp.StatusCode, []byte(data)) - upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(errBody), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header)) - upstreamErr.ResponseWritten = true - return &usage, upstreamErr - } - // 已发送部分事件:流已污染无法回写错误体,直接返回错误终止处理 - return &usage, constant.NewUpstreamError(resp.StatusCode, string(errBody), nil) - } - - var chunk dto.ChatCompletionStreamResponse - if err := json.Unmarshal([]byte(data), &chunk); err != nil { - // 解析失败不静默丢弃:记录日志便于定位上游返回非 chat 格式的问题 - // (如 responses 格式 SSE、纯 JSON 非流式体、空 body 等) - g.Log().Warningf(ctx, "[OpenAI.handleResponsesInboundStream] unmarshal chat stream chunk failed: %v, data: %.200s", err, data) - continue - } - parsedChunks++ - if len(chunk.Choices) > 0 { - sawChoices = true - } - - // 第一个 chunk:发送 response.created + output_item.added + content_part.added - if !sentCreated { - if chunk.ID != "" { - respID = fmt.Sprintf("resp_%s", chunk.ID) - msgID = fmt.Sprintf("msg_%s", chunk.ID) - } - if chunk.Created > 0 { - createdAt = int(chunk.Created) - } - if chunk.Model != "" && !info.ChannelMeta.IsModelMapped { - modelName = chunk.Model - } - - // response.created - EmitResponsesSSE(writer, "response.created", map[string]any{ - "type": "response.created", - "response": BuildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, info), - }) - - // response.output_item.added - EmitResponsesSSE(writer, "response.output_item.added", map[string]any{ - "type": "response.output_item.added", - "output_index": outputIndex, - "item": map[string]any{ - "type": "message", - "id": msgID, - "status": "in_progress", - "role": "assistant", - "content": []any{}, - }, - }) - - // response.content_part.added - EmitResponsesSSE(writer, "response.content_part.added", map[string]any{ - "type": "response.content_part.added", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]any{ - "type": "output_text", - "text": "", - "annotations": []any{}, - }, - }) - - sentCreated = true - } - - // 提取 usage - if chunk.Usage != nil { - usage.PromptTokens = chunk.Usage.PromptTokens - usage.CompletionTokens = chunk.Usage.CompletionTokens - usage.TotalTokens = chunk.Usage.TotalTokens - } - - // 处理 choices delta - for _, choice := range chunk.Choices { - // 文本内容 delta - if choice.Delta.Content != nil { - var deltaText string - switch v := choice.Delta.Content.(type) { - case string: - deltaText = v - } - if deltaText != "" { - contentBuilder.WriteString(deltaText) - EmitResponsesSSE(writer, "response.output_text.delta", map[string]any{ - "type": "response.output_text.delta", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "delta": deltaText, - }) - } - } - - // 推理内容 - if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { - EmitResponsesSSE(writer, "response.reasoning_summary_text.delta", map[string]any{ - "type": "response.reasoning_summary_text.delta", - "item_id": msgID, - "output_index": outputIndex, - "summary_index": 0, - "delta": *choice.Delta.ReasoningContent, - }) - } - - // 工具调用 - for _, tc := range choice.Delta.ToolCalls { - callID := tc.ID - - // 新 tool call:有 ID 和 name - if callID != "" && tc.Function.Name != "" { - // 记录 index → callID 映射,用于后续参数 chunk 的查找 - toolCallIDByIndex[tc.Index] = callID - - // 先关闭文本 content part - if !sentTextDone { - finishedText := contentBuilder.String() - EmitResponsesSSE(writer, "response.output_text.done", map[string]any{ - "type": "response.output_text.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "text": finishedText, - }) - EmitResponsesSSE(writer, "response.content_part.done", map[string]any{ - "type": "response.content_part.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]any{ - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }, - }) - EmitResponsesSSE(writer, "response.output_item.done", map[string]any{ - "type": "response.output_item.done", - "output_index": outputIndex, - "item": map[string]any{ - "type": "message", - "id": msgID, - "status": "completed", - "role": "assistant", - "content": []map[string]any{ - { - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }, - }, - }, - }) - sentTextDone = true - outputIndex++ - } - - toolCallIndexByID[callID] = outputIndex - toolCallNameByID[callID] = tc.Function.Name - toolCallArgsByID[callID] = "" - - EmitResponsesSSE(writer, "response.output_item.added", map[string]any{ - "type": "response.output_item.added", - "output_index": outputIndex, - "item": map[string]any{ - "type": "function_call", - "id": callID, - "call_id": callID, - "name": tc.Function.Name, - "status": "in_progress", - }, - }) - outputIndex++ - } - - // 参数 chunk:ID 可能为空,通过 index 查找对应的 callID - if callID == "" { - callID = toolCallIDByIndex[tc.Index] - } - if callID == "" { - continue - } - - // 工具调用 arguments 增量 - if tc.Function.Arguments != "" { - toolCallArgsByID[callID] += tc.Function.Arguments - EmitResponsesSSE(writer, "response.function_call_arguments.delta", map[string]any{ - "type": "response.function_call_arguments.delta", - "item_id": callID, - "output_index": toolCallIndexByID[callID], - "delta": tc.Function.Arguments, - }) - } - } - - // finish_reason - if choice.FinishReason != nil && *choice.FinishReason != "" { - finishedText := contentBuilder.String() - - // 关闭文本 content part(如果尚未关闭) - if !sentTextDone { - EmitResponsesSSE(writer, "response.output_text.done", map[string]any{ - "type": "response.output_text.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "text": finishedText, - }) - EmitResponsesSSE(writer, "response.content_part.done", map[string]any{ - "type": "response.content_part.done", - "item_id": msgID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]any{ - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }, - }) - EmitResponsesSSE(writer, "response.output_item.done", map[string]any{ - "type": "response.output_item.done", - "output_index": outputIndex, - "item": map[string]any{ - "type": "message", - "id": msgID, - "status": "completed", - "role": "assistant", - "content": []map[string]any{ - { - "type": "output_text", - "text": finishedText, - "annotations": []any{}, - }, - }, - }, - }) - } - - // 发送每个 tool call 的 function_call_arguments.done + output_item.done - for tcID, tcIdx := range toolCallIndexByID { - EmitResponsesSSE(writer, "response.function_call_arguments.done", map[string]any{ - "type": "response.function_call_arguments.done", - "item_id": tcID, - "output_index": tcIdx, - "arguments": toolCallArgsByID[tcID], - }) - EmitResponsesSSE(writer, "response.output_item.done", map[string]any{ - "type": "response.output_item.done", - "output_index": tcIdx, - "item": map[string]any{ - "type": "function_call", - "id": tcID, - "call_id": tcID, - "name": toolCallNameByID[tcID], - "arguments": toolCallArgsByID[tcID], - "status": "completed", - }, - }) - } - } - } - } - - if err := scanner.Err(); err != nil { - if err != io.EOF && ctx.Err() == nil { - info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) - return &usage, fmt.Errorf("stream scanner error: %w", err) - } - } - - // 上游流始终不是 chat 格式(全程无 choices 且无内容/工具调用/usage)时不再静默合成空响应: - // 典型形态包括 Responses/Claude 格式 SSE(JSON 能解析为空 chat chunk)、非流式 JSON 体、空 body 等。 - // 假成功的空 response.completed 会让客户端"成功但无内容",必须报错驱动重试换渠道/健康上报。 - if !sawChoices && contentBuilder.Len() == 0 && len(toolCallIndexByID) == 0 && usage.TotalTokens == 0 { - errUpstream := fmt.Errorf("upstream stream is not chat completions format: %d chunks parsed, none contained choices", parsedChunks) - g.Log().Warningf(ctx, - "[OpenAI.handleResponsesInboundStream] %v: channel=%d(%s) model=%s request_id=%s", - errUpstream, info.ChannelMeta.ChannelID, info.ChannelMeta.ChannelName, info.OriginModelName, info.RequestID) - info.StreamStatus.SetEndReason(common.StreamEndReasonError, errUpstream) - if !sentCreated { - // 尚未向客户端发送任何事件:写入错误响应(与 SSE 开始前的非 200 分支行为一致) - errBody, _ := json.Marshal(map[string]any{ - "error": map[string]any{ - "message": "upstream returned a non-chat-completions stream", - "type": "upstream_error", - "param": nil, - "code": "upstream_protocol_mismatch", - }, - }) - writeUpstreamErrorResponse(writer, http.StatusBadGateway, errBody) - upstreamErr := constant.NewUpstreamError(http.StatusBadGateway, string(errBody), nil) - upstreamErr.ResponseWritten = true - return &usage, upstreamErr - } - // 已发送部分事件:流已污染无法回写错误体,直接返回错误终止处理 - return &usage, constant.NewUpstreamError(http.StatusBadGateway, errUpstream.Error(), nil) - } - - // 估算 usage(正常结束 4 字符/token;scanner 异常等部分传输场景 2 字符/token) - if usage.CompletionTokens == 0 { - text := contentBuilder.String() - if len(text) > 0 { - usage.CompletionTokens = helper.EstimateStreamOutputTokens(info, len(text)) - } - } - if usage.TotalTokens == 0 { - usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens - } - // 流中断:输入 token 用请求侧估算值补齐,保证输入正常计费 - helper.ApplyInterruptedUsageFallback(info, &usage, contentBuilder.Len()) - - // 构建 response.completed 的 output 数组(包含文本消息 + 所有 tool call) - finalOutput := make([]map[string]any, 0) - if !sentTextDone || contentBuilder.Len() > 0 { - finalOutput = append(finalOutput, map[string]any{ - "type": "message", - "id": msgID, - "status": "completed", - "role": "assistant", - "content": []map[string]any{ - { - "type": "output_text", - "text": contentBuilder.String(), - "annotations": []any{}, - }, - }, - }) - } - for tcID := range toolCallIndexByID { - finalOutput = append(finalOutput, map[string]any{ - "type": "function_call", - "id": tcID, - "call_id": tcID, - "name": toolCallNameByID[tcID], - "arguments": toolCallArgsByID[tcID], - "status": "completed", - }) - } - - // response.completed - completedAt := int(time.Now().Unix()) - EmitResponsesSSE(writer, "response.completed", map[string]any{ - "type": "response.completed", - "response": BuildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, BuildResponsesUsageMap(&usage), &completedAt, info), - }) - - if info.StreamStatus.GetEndReason() == "" { - info.StreamStatus.SetEndReason(common.StreamEndReasonDone, nil) - } - - usage.CacheIncludedInPrompt = true - return &usage, nil -} - -// extractStreamEmbeddedError 检测 SSE data 行中内嵌的上游错误对象(存在 "error" 键且值非 null)。 -// 与 isUpstreamOpenAIError 的区别:后者用于完整响应体且不区分 null;这里对流式 chunk 逐行 -// 检测,并排除 "error":null(部分供应商的正常 chunk 会携带空 error 字段)。 -func extractStreamEmbeddedError(data []byte) (json.RawMessage, bool) { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, false - } - errBody, ok := raw["error"] - if !ok { - return nil, false - } - trimmed := string(bytes.TrimSpace(errBody)) - if trimmed == "" || trimmed == "null" { - return nil, false - } - return errBody, true -} - -// EmitResponsesSSE 发送一个 Responses API 格式的 SSE 事件(导出供 claude 等适配器的 Responses 桥接复用)。 -// 池化缓冲,整帧单次写出,零分配;序列化失败时跳过该事件并记日志(空 data 帧会让客户端解析失败)。 -func EmitResponsesSSE(w http.ResponseWriter, eventType string, data any) { - if err := helper.WriteSSEEventJSON(w, eventType, data); errors.Is(err, helper.ErrSSEPayloadMarshal) { - g.Log().Errorf(context.Background(), "[ResponsesSSE] marshal %s event failed, event skipped: %v", eventType, err) - } -} - // buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map。 // 请求参数从 info.ResponsesRequest echo(快照缺失时回退默认值); // store 恒为 false——合成响应不落上游存储,客户端不可经生命周期端点 retrieve。 diff --git a/relay/channel/registry.go b/relay/channel/registry.go index 2bda3a3..f438e91 100644 --- a/relay/channel/registry.go +++ b/relay/channel/registry.go @@ -7,9 +7,7 @@ import ( "github.com/qianfree/team-api/relay/channel/claude" "github.com/qianfree/team-api/relay/channel/cloudflare" "github.com/qianfree/team-api/relay/channel/codex" - "github.com/qianfree/team-api/relay/channel/coze" "github.com/qianfree/team-api/relay/channel/deepseek" - "github.com/qianfree/team-api/relay/channel/dify" "github.com/qianfree/team-api/relay/channel/gemini" "github.com/qianfree/team-api/relay/channel/jimeng" "github.com/qianfree/team-api/relay/channel/minimax" @@ -75,10 +73,6 @@ func GetAdaptor(providerType int) common.Adaptor { return &tencent.DispatchAdaptor{} case constant.ProviderXunfei: return &xunfei.Adaptor{} - case constant.ProviderCoze: - return &coze.Adaptor{} - case constant.ProviderDify: - return &dify.Adaptor{} case constant.ProviderJimeng: return &jimeng.Adaptor{} case constant.ProviderCodex: diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go index 680085b..b91e3fb 100644 --- a/relay/channel/siliconflow/adaptor.go +++ b/relay/channel/siliconflow/adaptor.go @@ -34,7 +34,10 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { baseURL := strings.TrimSuffix(info.ChannelMeta.BaseURL, "/") switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions: + // Claude/Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeClaudeMessages, + constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: return baseURL + "/v1/chat/completions", nil case constant.RelayModeCompletions: return baseURL + "/v1/completions", nil @@ -59,15 +62,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // 对于图像生成请求,将 "size" 映射为 "image_size","n" 映射为 "batch_size" // 其他请求只做模型名替换 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - c, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = c - } - var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { return bytes.NewReader(requestBody), nil diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go index ddcbf2c..3603172 100644 --- a/relay/channel/submodel/adaptor.go +++ b/relay/channel/submodel/adaptor.go @@ -41,15 +41,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。 // 仅允许 ChatCompletions 模式,其余模式返回错误。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if constant.RelayMode(info.RelayMode) != constant.RelayModeChatCompletions { return nil, fmt.Errorf("submodel: endpoint not supported") } diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go index 636b1e2..a2a6ae6 100644 --- a/relay/channel/tencent/adaptor.go +++ b/relay/channel/tencent/adaptor.go @@ -92,15 +92,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。腾讯混元新版 API 兼容 OpenAI 格式,做模型名映射即可。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index c743d34..57d1007 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -1,6 +1,7 @@ package vertex import ( + "bytes" "context" "fmt" "io" @@ -10,6 +11,8 @@ import ( "github.com/qianfree/team-api/relay/channel/claude" "github.com/qianfree/team-api/relay/channel/gemini" "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/helper" "github.com/qianfree/team-api/relay/override" ) @@ -56,9 +59,10 @@ func (a *Adaptor) Init(info *common.RelayInfo) { // 默认区域 a.region = "us-central1" - // 检测模型类型 - model := info.ChannelMeta.UpstreamModelName - if strings.Contains(strings.ToLower(model), "claude") { + // 检测模型类型。判据复用 helper 的权威实现——矩阵的协议判定 + //(EffectiveUpstreamFormat / inboundMatchesChannelNative)走的是同一个函数, + // 两侧一旦漂移就会出现「矩阵按 A 格式转换、adaptor 按 B 格式选端点」的错配。 + if helper.ProviderNativeFormatFor(info) == constant.RelayFormatClaude { a.detectedType = modelTypeClaude } else { a.detectedType = modelTypeGemini @@ -124,16 +128,37 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) return nil } -// ConvertRequest 转换请求体(委托给对应的供应商适配器) +// ConvertRequest 转换请求体(委托给对应的供应商适配器)。 +// Claude 模型另需把体改写为 Vertex rawPredict 端点的形态。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { switch a.detectedType { case modelTypeClaude: - return a.claudeAdaptor.ConvertRequest(ctx, info, requestBody) + converted, err := a.claudeAdaptor.ConvertRequest(ctx, info, requestBody) + if err != nil { + return nil, err + } + body, err := io.ReadAll(converted) + if err != nil { + return nil, fmt.Errorf("read converted claude body: %w", err) + } + return bytes.NewReader(adaptClaudeBodyForVertex(body)), nil default: return a.geminiAdaptor.ConvertRequest(ctx, info, requestBody) } } +// PostProcessConvertedRequest 供应商私有后处理(common.RequestPostProcessor)。 +// +// relaykit 接管转换时 ConvertRequest 整个不会被调用,上面那段 Vertex 专属的体改写 +// 会随之丢失(缺 anthropic_version 直接 400)。此处按接口契约接回,与 ConvertRequest +// 共用 adaptClaudeBodyForVertex;函数幂等,重复执行无副作用。 +func (a *Adaptor) PostProcessConvertedRequest(_ context.Context, _ *common.RelayInfo, body []byte) ([]byte, error) { + if a.detectedType != modelTypeClaude { + return body, nil + } + return adaptClaudeBodyForVertex(body), nil +} + // DoRequest 发送请求到上游 func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { reqURL, err := a.GetRequestURL(info) diff --git a/relay/channel/vertex/adaptor_test.go b/relay/channel/vertex/adaptor_test.go new file mode 100644 index 0000000..9f065a8 --- /dev/null +++ b/relay/channel/vertex/adaptor_test.go @@ -0,0 +1,177 @@ +package vertex + +import ( + "context" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +func vertexTestInfo(model string) *common.RelayInfo { + return &common.RelayInfo{ + OriginModelName: model, + InboundFormat: constant.RelayFormatClaude, + ClientFormat: constant.RelayFormatClaude, + RelayMode: int(constant.RelayModeClaudeMessages), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderVertex), + BaseURL: "https://us-central1-aiplatform.googleapis.com", + ApiKey: "test-project", + UpstreamModelName: model, + }, + } +} + +// TestInit_ModelDrivenProtocolDetection 模型名决定协议分流;判据与 helper 的 +// 权威实现共用,本测试同时钉住「URL 端点」与「协议判定」两者一致。 +func TestInit_ModelDrivenProtocolDetection(t *testing.T) { + tests := []struct { + model string + wantType modelType + wantURLParts []string + }{ + {"gemini-2.5-pro", modelTypeGemini, []string{"publishers/google/models/gemini-2.5-pro", ":generateContent"}}, + {"claude-sonnet-4-5", modelTypeClaude, []string{"publishers/anthropic/models/claude-sonnet-4-5", ":rawPredict"}}, + {"CLAUDE-OPUS-4", modelTypeClaude, []string{"publishers/anthropic/", ":rawPredict"}}, + {"text-embedding-004", modelTypeGemini, []string{"publishers/google/", ":generateContent"}}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + a := &Adaptor{} + info := vertexTestInfo(tt.model) + a.Init(info) + + if a.detectedType != tt.wantType { + t.Errorf("detectedType = %v, want %v", a.detectedType, tt.wantType) + } + + url, err := a.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL 失败: %v", err) + } + for _, part := range tt.wantURLParts { + if !strings.Contains(url, part) { + t.Errorf("URL = %q,期望包含 %q", url, part) + } + } + }) + } +} + +// TestAdaptClaudeBodyForVertex Vertex Anthropic 端点的体改写: +// 注入 anthropic_version(必填)、删除 model(模型由 URL 指定)。 +func TestAdaptClaudeBodyForVertex(t *testing.T) { + t.Run("注入版本并删除 model", func(t *testing.T) { + out := adaptClaudeBodyForVertex([]byte(`{"model":"claude-sonnet-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`)) + + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("输出不是合法 JSON: %v", err) + } + if _, exists := obj["model"]; exists { + t.Error("model 字段未被删除(Vertex rawPredict 端点不接受体内 model)") + } + if obj["anthropic_version"] != vertexAnthropicVersion { + t.Errorf("anthropic_version = %v, want %q", obj["anthropic_version"], vertexAnthropicVersion) + } + // 其余字段必须原样保留 + if obj["max_tokens"] != float64(1024) { + t.Errorf("max_tokens 丢失或变形: %v", obj["max_tokens"]) + } + if _, ok := obj["messages"]; !ok { + t.Error("messages 丢失") + } + }) + + t.Run("幂等", func(t *testing.T) { + in := []byte(`{"model":"claude-x","max_tokens":1,"messages":[]}`) + once := adaptClaudeBodyForVertex(in) + twice := adaptClaudeBodyForVertex(once) + if string(once) != string(twice) { + t.Errorf("重复执行结果不同(RequestPostProcessor 契约要求幂等):\n once=%s\ntwice=%s", once, twice) + } + }) + + t.Run("尊重客户端显式指定的版本", func(t *testing.T) { + out := adaptClaudeBodyForVertex([]byte(`{"anthropic_version":"vertex-2099-01-01","messages":[]}`)) + var obj map[string]any + if err := json.Unmarshal(out, &obj); err != nil { + t.Fatalf("输出不是合法 JSON: %v", err) + } + if obj["anthropic_version"] != "vertex-2099-01-01" { + t.Errorf("覆盖了客户端指定的版本: %v", obj["anthropic_version"]) + } + }) + + t.Run("非 JSON 体原样返回", func(t *testing.T) { + in := []byte("not json at all") + if got := adaptClaudeBodyForVertex(in); string(got) != string(in) { + t.Errorf("非 JSON 体被改动: %s", got) + } + }) +} + +// TestPostProcessConvertedRequest relaykit 接管路径下的私有后处理: +// Claude 模型改写体,Gemini 模型原样返回。 +func TestPostProcessConvertedRequest(t *testing.T) { + claudeBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":1,"messages":[]}`) + + a := &Adaptor{} + a.Init(vertexTestInfo("claude-sonnet-4-5")) + out, err := a.PostProcessConvertedRequest(context.Background(), nil, claudeBody) + if err != nil { + t.Fatalf("后处理失败: %v", err) + } + if strings.Contains(string(out), `"model"`) { + t.Errorf("Claude 模型的体仍含 model 字段: %s", out) + } + if !strings.Contains(string(out), vertexAnthropicVersion) { + t.Errorf("Claude 模型的体缺少 anthropic_version: %s", out) + } + + geminiBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`) + g := &Adaptor{} + g.Init(vertexTestInfo("gemini-2.5-pro")) + out, err = g.PostProcessConvertedRequest(context.Background(), nil, geminiBody) + if err != nil { + t.Fatalf("后处理失败: %v", err) + } + if string(out) != string(geminiBody) { + t.Errorf("Gemini 模型的体不应被改动:\n got=%s\nwant=%s", out, geminiBody) + } +} + +// TestConvertRequest_ClaudeAppliesVertexShape legacy 路径(adaptor.ConvertRequest) +// 必须与 relaykit 路径(PostProcessConvertedRequest)得到同样的体形态—— +// 两条路径行为漂移正是 RequestPostProcessor 接口契约要防的事。 +func TestConvertRequest_ClaudeAppliesVertexShape(t *testing.T) { + a := &Adaptor{} + info := vertexTestInfo("claude-sonnet-4-5") + a.Init(info) + + r, err := a.ConvertRequest(context.Background(), info, + []byte(`{"model":"origin-model","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`)) + if err != nil { + t.Fatalf("ConvertRequest 失败: %v", err) + } + body, err := io.ReadAll(r) + if err != nil { + t.Fatalf("读取转换结果失败: %v", err) + } + + var obj map[string]any + if err := json.Unmarshal(body, &obj); err != nil { + t.Fatalf("结果不是合法 JSON: %v", err) + } + if _, exists := obj["model"]; exists { + t.Errorf("legacy 路径未删除 model 字段: %s", body) + } + if obj["anthropic_version"] != vertexAnthropicVersion { + t.Errorf("legacy 路径未注入 anthropic_version: %s", body) + } +} diff --git a/relay/channel/vertex/request.go b/relay/channel/vertex/request.go new file mode 100644 index 0000000..ecd8475 --- /dev/null +++ b/relay/channel/vertex/request.go @@ -0,0 +1,37 @@ +package vertex + +import "encoding/json" + +// Vertex AI 上 Anthropic 模型的请求体与标准 Claude Messages API 有两处差异, +// 二者都由本文件统一处理(adaptor.ConvertRequest 与 PostProcessConvertedRequest +// 共用同一实现,避免 legacy 路径与 relaykit 路径行为漂移)。 + +// vertexAnthropicVersion Vertex AI 上 Anthropic 模型要求的 API 版本标识。 +// 这是 Vertex rawPredict 端点的必填字段(标准 Anthropic API 由 anthropic-version +// 请求头承载,Vertex 改为放进请求体)。 +const vertexAnthropicVersion = "vertex-2023-10-16" + +// adaptClaudeBodyForVertex 把标准 Claude Messages 请求体改写为 Vertex +// publishers/anthropic/{model}:rawPredict 端点要求的形态: +// - 注入 anthropic_version(必填); +// - 删除 model 字段——模型由 URL 路径指定,体内不接受。 +// +// 非 JSON 体原样返回:网关不替上游判定合法性,让上游给出真实错误更利于排查。 +// 已带 anthropic_version 的体不覆盖(尊重客户端显式指定的版本)。 +func adaptClaudeBodyForVertex(body []byte) []byte { + var fields map[string]json.RawMessage + if err := json.Unmarshal(body, &fields); err != nil { + return body + } + + delete(fields, "model") + if _, ok := fields["anthropic_version"]; !ok { + fields["anthropic_version"] = json.RawMessage(`"` + vertexAnthropicVersion + `"`) + } + + out, err := json.Marshal(fields) + if err != nil { + return body + } + return out +} diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 0bd0f9a..182c6b5 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -31,7 +31,9 @@ func (a *openaiAdaptor) GetRequestURL(info *common.RelayInfo) (string, error) { } switch constant.RelayMode(info.RelayMode) { - case constant.RelayModeChatCompletions: + // Gemini/Responses 入站:矩阵已把请求体转成 OpenAI chat 格式,走 chat 端点 + case constant.RelayModeChatCompletions, constant.RelayModeGeminiChat, + constant.RelayModeResponses, constant.RelayModeResponsesCompact: if strings.HasPrefix(modelName, "bot-") { return baseURL + "/api/v3/bots/chat/completions", nil } diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 0f56faf..9d34851 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -57,53 +57,31 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // // 剥离后缀后再做模型名映射(如果配置了映射)。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted + processed, err := postProcessRequest(requestBody, info) + if err != nil { + return nil, err } + return bytes.NewReader(processed), nil +} +// postProcessRequest xAI 请求适配:模型名映射。 +// 历史上还承担 -search/-high/-low 模型名后缀的剥离与 search_parameters/ +// reasoning_effort 注入;该后缀语法已于 2026-09 随全局虚拟模型后缀机制一并移除。 +// 模型映射已由 relaykit 覆盖,故不再实现 RequestPostProcessor 钩子。 +func postProcessRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { + if !info.ChannelMeta.IsModelMapped { + return requestBody, nil + } var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { - return bytes.NewReader(requestBody), nil + return requestBody, nil } - - // 获取当前模型名 - modelName := info.OriginModelName - - // 处理 "-search" 后缀 - if strings.HasSuffix(modelName, "-search") { - modelName = strings.TrimSuffix(modelName, "-search") - rawMap["search_parameters"] = json.RawMessage(`{"mode":"on"}`) - } - - // 处理 "-high" 后缀 - if strings.HasSuffix(modelName, "-high") { - modelName = strings.TrimSuffix(modelName, "-high") - rawMap["reasoning_effort"] = json.RawMessage(`"high"`) - } - - // 处理 "-low" 后缀 - if strings.HasSuffix(modelName, "-low") { - modelName = strings.TrimSuffix(modelName, "-low") - rawMap["reasoning_effort"] = json.RawMessage(`"low"`) - } - - // 模型名映射:优先使用上游映射名,否则使用剥离后缀后的名称 - if info.ChannelMeta.IsModelMapped { - rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) - } else { - rawMap["model"] = json.RawMessage(`"` + modelName + `"`) - } - + rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) converted, err := json.Marshal(rawMap) if err != nil { return nil, fmt.Errorf("marshal converted request failed: %w", err) } - return bytes.NewReader(converted), nil + return converted, nil } func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go index 8bc699a..45626d2 100644 --- a/relay/channel/xunfei/adaptor.go +++ b/relay/channel/xunfei/adaptor.go @@ -52,15 +52,6 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo) // ConvertRequest 转换请求体。讯飞 OpenAI 兼容接口格式与 OpenAI 一致, // 只需做模型名映射。 func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) { - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted - } - if info.ChannelMeta.IsModelMapped { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go index 8a3b5f9..a7e3539 100644 --- a/relay/channel/zhipu/adaptor.go +++ b/relay/channel/zhipu/adaptor.go @@ -75,18 +75,25 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re return convertClaudeRequestForZhipu(requestBody, info) } - // 非 OpenAI 格式先转换为 OpenAI - if info.InboundFormat != "" && info.InboundFormat != constant.RelayFormatOpenAI { - converted, err := openai.ConvertToOpenAI(requestBody, info) - if err != nil { - return nil, err - } - requestBody = converted + processed, err := postProcessRequest(requestBody, info) + if err != nil { + return nil, err } + return bytes.NewReader(processed), nil +} +// PostProcessConvertedRequest 见 common.RequestPostProcessor: +// relaykit 完成格式转换后接回智谱私有适配(GLM 参数兼容:top_p 裁剪、图片前缀剥离)。 +func (a *Adaptor) PostProcessConvertedRequest(ctx context.Context, info *common.RelayInfo, body []byte) ([]byte, error) { + return postProcessRequest(body, info) +} + +// postProcessRequest 智谱私有请求适配:请求体须已是 OpenAI chat 格式。 +// 与 relaykit 通用处理重叠的部分(model / stream_options)为覆盖写同值,幂等。 +func postProcessRequest(requestBody []byte, info *common.RelayInfo) ([]byte, error) { var rawMap map[string]json.RawMessage if err := json.Unmarshal(requestBody, &rawMap); err != nil { - return bytes.NewReader(requestBody), nil + return requestBody, nil } // GLM 特有的转换(TopP 裁剪、图片前缀剥离) @@ -95,9 +102,6 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re // 注入 stream_options(流式请求需要 usage 信息用于计费) rawMap = injectStreamOptions(rawMap, info) - // 注入思考模式参数 - rawMap = injectThinkingParams(rawMap, info) - // 模型名映射 if info.ChannelMeta.IsModelMapped { rawMap["model"] = json.RawMessage(`"` + info.ChannelMeta.UpstreamModelName + `"`) @@ -107,7 +111,7 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re if err != nil { return nil, fmt.Errorf("marshal converted request failed: %w", err) } - return bytes.NewReader(result), nil + return result, nil } // injectStreamOptions 为流式请求注入 stream_options:{include_usage:true} @@ -121,37 +125,6 @@ func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayIn return rawMap } -// injectThinkingParams 根据 RelayInfo 中的思考后缀注入 GLM 思考模式参数。 -// -// GLM 思考模式参数说明(仅 GLM-4.5 及以上模型支持): -// - thinking.type: "enabled"(默认) / "disabled" -// - GLM-5.1/5/5v-Turbo/4.7/4.5V:强制思考 -// - GLM-4.6/4.6V/4.5:模型自动判断是否思考 -// - thinking.clear_thinking: 控制是否清除历史 reasoning_content(默认 true) -// -// 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 GLM 默认行为) -func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage { - // 客户端已显式设置 thinking 参数 → 不干预 - if _, clientSet := rawMap["thinking"]; clientSet { - return rawMap - } - - // -nothinking 后缀:显式关闭思考 - if info.ThinkingDisabled { - rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`) - return rawMap - } - - // -thinking 后缀:显式开启思考 - if info.ThinkingEnabled { - rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`) - return rawMap - } - - // 无后缀:不干预,保留 GLM 默认行为(默认 enabled) - return rawMap -} - // applyGLMCompatibility 处理 GLM 特有的兼容性问题:TopP 上限裁剪、base64 图片前缀剥离 func applyGLMCompatibility(rawMap map[string]json.RawMessage) map[string]json.RawMessage { // TopP 裁剪:GLM 要求 top_p < 1.0 diff --git a/relay/channel/zhipu/post_process_test.go b/relay/channel/zhipu/post_process_test.go new file mode 100644 index 0000000..ef8ac55 --- /dev/null +++ b/relay/channel/zhipu/post_process_test.go @@ -0,0 +1,8 @@ +package zhipu + +import ( + "github.com/qianfree/team-api/relay/common" +) + +// relaykit 接管转换后,私有请求适配靠这个可选接口接回;断言实现不掉。 +var _ common.RequestPostProcessor = (*Adaptor)(nil) diff --git a/relay/common/adaptor.go b/relay/common/adaptor.go index e763ba4..aba2946 100644 --- a/relay/common/adaptor.go +++ b/relay/common/adaptor.go @@ -30,3 +30,29 @@ type Adaptor interface { // GetChannelName 返回渠道名称(用于日志) GetChannelName() string } + +// RequestPostProcessor 供应商私有的「转换后请求体后处理」可选能力。 +// +// 为什么需要:relaykit 的转换矩阵按**协议格式**裁决(入站格式 × 有效上游格式), +// 与供应商无关;命中后 Adaptor.ConvertRequest 整个不再被调用。但部分 OpenAI 兼容 +// 供应商在格式转换之外还有私有请求适配——参数兼容裁剪(zhipu 的 GLM top_p 裁剪与 +// 图片前缀剥离、ali 的 DashScope thinking_budget 剥离)。这些都不属于协议转换, +// 不能塞进 relaykit(它必须与宿主和供应商解耦),但漏掉就会静默降级甚至上游 400。 +// +// (历史上 xai/baidu_v2/deepseek 也实现过本接口,承载模型名能力后缀(-search 等) +// 的剥离与思考方言注入;该后缀语法已于 2026-09 移除,其余职责由 relaykit 通用 +// 处理覆盖后,三家的实现一并删除。) +// +// 因此由本接口把这段私有后处理接回 relaykit 路径:实现方只处理「格式转换已完成、 +// 请求体已是上游主协议」之后的那一段,与自身 ConvertRequest 的尾段共用同一实现。 +// +// 契约: +// - 仅在 relaykit 接管了转换时由 handler 调用;走 adaptor.ConvertRequest 的路径 +// 不调用(那条路径的 ConvertRequest 内部已含同一段后处理,重复执行可能双写参数); +// - 入参 body 是 relaykit 转换后的上游主协议请求体,返回替换后的完整请求体; +// - 必须幂等于 relaykit 已做的通用处理(模型名替换、stream_options): +// 覆盖写同值无害,但不得依赖「字段一定不存在」; +// - 返回 error 即拒绝请求(hard-fail,与转换失败同等对待)。 +type RequestPostProcessor interface { + PostProcessConvertedRequest(ctx context.Context, info *RelayInfo, body []byte) ([]byte, error) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index e41f707..54b0185 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -131,6 +131,10 @@ type ChannelSettings struct { // UseProxy 启用代理,使用系统配置的代理地址转发请求 UseProxy bool `json:"use_proxy,omitempty"` + // WebSearchToGoogleSearch 将客户端请求的服务端 web_search 工具映射为 Gemini 原生 + // googleSearch(仅 Gemini 上游生效;grounded 请求 Google 按搜索次数另行计价,默认关闭) + WebSearchToGoogleSearch bool `json:"web_search_to_google_search,omitempty"` + // DebugLogEnabled 启用渠道调试日志:记录经该渠道每次请求尝试的四段完整报文 //(客户端↔系统↔上游,body 不截断、凭证脱敏)。数据量大,仅排查问题时开启 DebugLogEnabled bool `json:"debug_log_enabled,omitempty"` @@ -207,10 +211,7 @@ type RelayInfo struct { ResponsesRequest *dto.OpenAIResponsesRequest // Thinking 后缀路由(从模型名解析,供适配器消费) - ThinkingEnabled bool // 是否有 -thinking 后缀 - ThinkingDisabled bool // 是否有 -nothinking 后缀 - ReasoningEffort string // effort 级别:low/medium/high/xhigh/max/minimal - BaseModelName string // 去除 thinking/effort 后缀的基础模型名 + BaseModelName string // 计费/调度用的目录模型名(lookup 口径,现恒等于 OriginModelName) // WebSocket 连接(仅 Realtime 模式使用) ClientConn interface{} // *websocket.Conn — 使用 interface{} 避免 relay 层直接依赖 gorilla/websocket @@ -297,20 +298,6 @@ func (info *RelayInfo) GetIsStream() bool { return info.IsStream } -func (info *RelayInfo) GetReasoningEffort() string { - if info == nil { - return "" - } - return info.ReasoningEffort -} - -func (info *RelayInfo) SetReasoningEffort(effort string) { - if info == nil { - return - } - info.ReasoningEffort = effort -} - func (info *RelayInfo) GetEstimatePromptTokens() int { if info == nil { return 0 @@ -370,6 +357,24 @@ func (info *RelayInfo) ConversionChain() []types.RelayFormat { return info.conversionChain } +// convmeta.ResponsesStash 能力接口实现:r2c 转换器把解析后的 Responses 入站请求 +// 快照存进 RelayInfo.ResponsesRequest,响应合成侧(chat→Responses 回显)经此读取。 +var _ convmeta.ResponsesStash = (*RelayInfo)(nil) + +func (info *RelayInfo) StashResponsesRequest(req *dto.OpenAIResponsesRequest) { + if info == nil { + return + } + info.ResponsesRequest = req +} + +func (info *RelayInfo) StashedResponsesRequest() *dto.OpenAIResponsesRequest { + if info == nil { + return nil + } + return info.ResponsesRequest +} + func (info *RelayInfo) ConvOptions() *convmeta.Options { if info == nil { return &convmeta.Options{} @@ -384,19 +389,15 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options { func (info *RelayInfo) buildConvOptions() *convmeta.Options { opts := &convmeta.Options{ Claude: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: true, // TODO: 从配置读取 - ThinkingAdapterBudgetTokensPercentage: 0.5, // TODO: 从配置读取 - DefaultMaxTokens: defaultMaxTokensForClaude, + DefaultMaxTokens: defaultMaxTokensForClaude, }, Gemini: convmeta.GeminiOptions{ - ThinkingAdapterEnabled: true, // TODO: 从配置读取 - ThinkingAdapterBudgetTokensPercentage: 0.5, // TODO: 从配置读取 - FunctionCallThoughtSignatureEnabled: true, // TODO: 从配置读取 - SupportsImagine: supportsImagineModel, - SafetySetting: nil, // TODO: 从配置读取 + FunctionCallThoughtSignatureEnabled: true, // TODO: 从配置读取 + SupportsImagine: supportsImagineModel, + SafetySetting: nil, // TODO: 从配置读取 + WebSearchToGoogleSearch: info.ChannelMeta != nil && info.ChannelMeta.Settings.WebSearchToGoogleSearch, }, - OpenRouterDialect: info.ChannelMeta != nil && info.ChannelMeta.ChannelType == int(constant.ProviderOpenRouter), - PreserveThinkingSuffix: nil, // TODO: 实现黑名单检查 + OpenRouterDialect: info.ChannelMeta != nil && info.ChannelMeta.ChannelType == int(constant.ProviderOpenRouter), } return opts } diff --git a/relay/constant/provider.go b/relay/constant/provider.go index ffda00c..7a18c01 100644 --- a/relay/constant/provider.go +++ b/relay/constant/provider.go @@ -29,16 +29,16 @@ const ( ProviderXInference ProviderType = 28 ProviderMiniMax ProviderType = 29 ProviderSubmodel ProviderType = 30 - ProviderCoze ProviderType = 32 - ProviderDify ProviderType = 33 - ProviderJimeng ProviderType = 34 - ProviderCodex ProviderType = 35 - ProviderSora ProviderType = 37 - ProviderKling ProviderType = 38 - ProviderSuno ProviderType = 39 - ProviderMidjourney ProviderType = 40 - ProviderNewAPI ProviderType = 41 - ProviderSub2API ProviderType = 42 + // 32 = Coze(扣子)、33 = Dify:渠道已于 v0.2 移除,编号永久保留不再复用, + // 避免历史 bil_usage_logs / aud_request_logs 中的 channel_type 被新渠道串味。 + ProviderJimeng ProviderType = 34 + ProviderCodex ProviderType = 35 + ProviderSora ProviderType = 37 + ProviderKling ProviderType = 38 + ProviderSuno ProviderType = 39 + ProviderMidjourney ProviderType = 40 + ProviderNewAPI ProviderType = 41 + ProviderSub2API ProviderType = 42 ) // String 返回供应商类型名称 @@ -94,10 +94,6 @@ func (p ProviderType) String() string { return "MiniMax" case ProviderSubmodel: return "Submodel" - case ProviderCoze: - return "Coze" - case ProviderDify: - return "Dify" case ProviderJimeng: return "Jimeng" case ProviderCodex: @@ -129,3 +125,28 @@ func IsMultiNativeProvider(providerType int) bool { } return false } + +// HasNativeClaudeEndpoint 判断供应商是否另挂 Anthropic 兼容端点。 +// +// 这类供应商主协议是 OpenAI(ProviderNativeFormat 返回 openai),但对 +// RelayModeClaudeMessages 另有独立的 Anthropic 兼容端点(见各 adaptor 的 +// GetRequestURL:/anthropic/v1/messages、/apps/anthropic/v1/messages、 +// /api/coding/v1/messages 等),Claude 入站时上游**实际说 Claude**: +// 请求体须按 Claude 原样发出、响应体也是 Claude 格式。 +// +// 因此协议格式不能只按渠道类型判定,必须叠加 relay mode—— +// 漏掉这条会把 Claude 请求转成 OpenAI chat 发到 Anthropic 端点(上游 400), +// 并把返回的 Claude 响应当 OpenAI 解析(转换失败)。 +// +// 新增此类供应商时同步登记:判据是 GetRequestURL 对 ClaudeMessages +// 返回了与 chat 端点**不同**的地址。若 ClaudeMessages 与 chat 共用同一端点 +// (aws / baidu_v2 / mistral / openai / xai / xunfei),则上游只说 OpenAI, +// 不属于本集合,Claude 入站仍须转换。 +func HasNativeClaudeEndpoint(providerType int) bool { + switch ProviderType(providerType) { + case ProviderAli, ProviderZhipu, ProviderDeepSeek, + ProviderMoonshot, ProviderVolcengine, ProviderMiniMax: + return true + } + return false +} diff --git a/relay/constant/relay_format.go b/relay/constant/relay_format.go index 1f15d6b..f923909 100644 --- a/relay/constant/relay_format.go +++ b/relay/constant/relay_format.go @@ -12,7 +12,5 @@ const ( // 剩余原生格式供应商(非 OpenAI 兼容)。 // 字符串值必须与 relaykit/types.RelayFormat 保持一致,桥接处依赖 types.RelayFormat(x) 强转。 - RelayFormatCoze RelayFormat = "coze" // 字节 Coze v3 - RelayFormatDify RelayFormat = "dify" // Dify chat-messages RelayFormatOllama RelayFormat = "ollama" // Ollama /api/chat ) diff --git a/relay/dto/openai_responses.go b/relay/dto/openai_responses.go index 8beedcc..1943fe2 100644 --- a/relay/dto/openai_responses.go +++ b/relay/dto/openai_responses.go @@ -1,294 +1,38 @@ package dto -import "encoding/json" +// Responses API 类型已下沉至 relaykit/dto(协议转换迁移),此处保留类型别名以兼容现有代码。 +// 使用类型别名机制,现有代码无需修改;方法(如 OpenAIResponsesResponse.HasError)随类型定义 +// 一并迁移,别名可直接调用。 +import relaykitdto "github.com/qianfree/team-api/relaykit/dto" -// ========== Responses API 请求 ========== +// ==================== Responses API 请求 ==================== -// OpenAIResponsesRequest OpenAI Responses API 请求 -// https://platform.openai.com/docs/api-reference/responses/create -type OpenAIResponsesRequest struct { - Model string `json:"model"` - Input json.RawMessage `json:"input,omitempty"` - Include json.RawMessage `json:"include,omitempty"` - Instructions json.RawMessage `json:"instructions,omitempty"` - MaxOutputTokens *uint `json:"max_output_tokens,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` - PreviousResponseID string `json:"previous_response_id,omitempty"` - Reasoning *Reasoning `json:"reasoning,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - Stream *bool `json:"stream,omitempty"` - StreamOptions *StreamOptions `json:"stream_options,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Logprobs *int `json:"logprobs,omitempty"` - TopLogProbs *int `json:"top_logprobs,omitempty"` - Truncation json.RawMessage `json:"truncation,omitempty"` - User json.RawMessage `json:"user,omitempty"` - MaxToolCalls *uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Conversation json.RawMessage `json:"conversation,omitempty"` - ContextManagement json.RawMessage `json:"context_management,omitempty"` - Background *bool `json:"background,omitempty"` - PromptCacheKey string `json:"prompt_cache_key,omitempty"` - PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` - SafetyIdentifier string `json:"safety_identifier,omitempty"` - FrequencyPenalty json.RawMessage `json:"frequency_penalty,omitempty"` - PresencePenalty json.RawMessage `json:"presence_penalty,omitempty"` -} +type OpenAIResponsesRequest = relaykitdto.OpenAIResponsesRequest +type Reasoning = relaykitdto.Reasoning -// Reasoning 推理参数(用于 o1/o3 等推理模型) -type Reasoning struct { - Effort string `json:"effort,omitempty"` - Summary string `json:"summary,omitempty"` -} +// ==================== Responses API 响应 ==================== -// ========== Responses API 响应 ========== +type OpenAIResponsesResponse = relaykitdto.OpenAIResponsesResponse +type IncompleteDetails = relaykitdto.IncompleteDetails +type ResponsesUsage = relaykitdto.ResponsesUsage +type ResponsesReasoning = relaykitdto.ResponsesReasoning +type ResponsesText = relaykitdto.ResponsesText +type ResponsesTextFormat = relaykitdto.ResponsesTextFormat +type InputTokenDetails = relaykitdto.InputTokenDetails +type OutputTokenDetails = relaykitdto.OutputTokenDetails +type ResponsesOutput = relaykitdto.ResponsesOutput +type ResponsesOutputContent = relaykitdto.ResponsesOutputContent +type ResponsesAnnotation = relaykitdto.ResponsesAnnotation +type ResponsesWebSearchAction = relaykitdto.ResponsesWebSearchAction +type ResponsesWebSearchSource = relaykitdto.ResponsesWebSearchSource +type ResponsesFileSearchResult = relaykitdto.ResponsesFileSearchResult +type ResponsesSafetyCheck = relaykitdto.ResponsesSafetyCheck +type ResponsesCodeInterpreterOutput = relaykitdto.ResponsesCodeInterpreterOutput +type ResponsesShellAction = relaykitdto.ResponsesShellAction +type ResponsesPatchAction = relaykitdto.ResponsesPatchAction +type ResponsesMCPTool = relaykitdto.ResponsesMCPTool -// OpenAIResponsesResponse Responses API 非流式响应 -type OpenAIResponsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` - CreatedAt int `json:"created_at"` - CompletedAt int `json:"completed_at,omitempty"` - Status json.RawMessage `json:"status"` - Error any `json:"error"` - IncompleteDetails any `json:"incomplete_details"` - Instructions any `json:"instructions"` - MaxOutputTokens *int `json:"max_output_tokens"` - MaxToolCalls *int `json:"max_tool_calls,omitempty"` - Model string `json:"model"` - Output []ResponsesOutput `json:"output"` - OutputText string `json:"output_text,omitempty"` - ParallelToolCalls bool `json:"parallel_tool_calls"` - PreviousResponseID any `json:"previous_response_id"` - Prompt any `json:"prompt"` - PromptCacheKey string `json:"prompt_cache_key,omitempty"` - PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` - Reasoning *ResponsesReasoning `json:"reasoning"` - SafetyIdentifier string `json:"safety_identifier,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store bool `json:"store"` - Background *bool `json:"background,omitempty"` - Conversation any `json:"conversation"` - Temperature *float64 `json:"temperature"` - Text *ResponsesText `json:"text,omitempty"` - ToolChoice any `json:"tool_choice"` - Tools []any `json:"tools"` - TopP *float64 `json:"top_p"` - Truncation any `json:"truncation"` - Usage *ResponsesUsage `json:"usage,omitempty"` - User any `json:"user"` - Metadata any `json:"metadata"` -} +// ==================== Responses API 流式响应 ==================== -// IncompleteDetails 不完整响应详情 -type IncompleteDetails struct { - Reason string `json:"reason,omitempty"` -} - -// ResponsesUsage Responses API 使用量(字段名与 Chat Completions 不同) -type ResponsesUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` - InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` - OutputTokenDetails *OutputTokenDetails `json:"output_tokens_details"` -} - -// ResponsesReasoning 推理配置(响应中的 reasoning 字段) -type ResponsesReasoning struct { - Effort any `json:"effort"` - Summary any `json:"summary"` -} - -// ResponsesText 文本配置(响应中的 text 字段) -type ResponsesText struct { - Format ResponsesTextFormat `json:"format"` -} - -// ResponsesTextFormat 文本格式 -type ResponsesTextFormat struct { - Type string `json:"type"` -} - -// InputTokenDetails 输入 token 细分 -type InputTokenDetails struct { - CachedTokens int `json:"cached_tokens"` - CacheWriteTokens int `json:"cache_write_tokens,omitempty"` // 本次写入缓存的 token(input_tokens 子集,按普通输入价计费) - TextTokens int `json:"text_tokens,omitempty"` - AudioTokens int `json:"audio_tokens,omitempty"` - ImageTokens int `json:"image_tokens,omitempty"` -} - -// OutputTokenDetails 输出 token 细分 -type OutputTokenDetails struct { - TextTokens int `json:"text_tokens,omitempty"` - AudioTokens int `json:"audio_tokens,omitempty"` - ReasoningTokens int `json:"reasoning_tokens,omitempty"` - AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` - RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` -} - -// ResponsesOutput 响应输出项(通用结构,支持所有 output item 类型) -// 具体 type 包括:message、function_call、web_search_call、file_search_call、 -// computer_call、reasoning、image_generation_call、code_interpreter_call、 -// shell_call、local_shell_call、apply_patch_call、mcp_call、mcp_list_tools、 -// mcp_approval_request、custom_tool_call、compaction -type ResponsesOutput struct { - Type string `json:"type"` - ID string `json:"id"` - Status string `json:"status,omitempty"` - Role string `json:"role,omitempty"` - Content []ResponsesOutputContent `json:"content,omitempty"` - CallID string `json:"call_id,omitempty"` - Name string `json:"name,omitempty"` - Arguments string `json:"arguments,omitempty"` - // action 字段为多态:web_search_call / shell_call / local_shell_call / apply_patch_call - // 各类型的 action 结构不同(见 ResponsesWebSearchAction / ResponsesShellAction / - // ResponsesPatchAction)。此处用 RawMessage 原样透传,避免多个字段共用同一 json tag - // 导致 encoding/json 在序列化和反序列化时静默丢弃全部 action 字段。 - Action json.RawMessage `json:"action,omitempty"` - // file_search_call - Queries []string `json:"queries,omitempty"` - Results []ResponsesFileSearchResult `json:"results,omitempty"` - // computer_call - PendingSafetyChecks []ResponsesSafetyCheck `json:"pending_safety_checks,omitempty"` - // reasoning - Summary []ResponsesSummaryPart `json:"summary,omitempty"` - EncryptedContent string `json:"encrypted_content,omitempty"` - // image_generation_call - Result string `json:"result,omitempty"` - // code_interpreter_call - Code string `json:"code,omitempty"` - ContainerID string `json:"container_id,omitempty"` - Outputs []ResponsesCodeInterpreterOutput `json:"outputs,omitempty"` - // mcp_call - ServerLabel string `json:"server_label,omitempty"` - Output string `json:"output,omitempty"` - Error string `json:"error,omitempty"` - ApprovalRequestID string `json:"approval_request_id,omitempty"` - // mcp_list_tools - Tools []ResponsesMCPTool `json:"tools,omitempty"` - // mcp_approval_request - // custom_tool_call - Input string `json:"input,omitempty"` -} - -// ResponsesOutputContent 响应输出内容块 -type ResponsesOutputContent struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - Annotations []ResponsesAnnotation `json:"annotations,omitempty"` - // refusal 内容类型 - Refusal string `json:"refusal,omitempty"` - // reasoning_text 内容类型 -} - -// ResponsesAnnotation 注解类型(联合结构,支持所有注解子类型) -type ResponsesAnnotation struct { - Type string `json:"type"` - FileID string `json:"file_id,omitempty"` - Filename string `json:"filename,omitempty"` - Index int `json:"index,omitempty"` - // url_citation - URL string `json:"url,omitempty"` - Title string `json:"title,omitempty"` - StartIndex int `json:"start_index,omitempty"` - EndIndex int `json:"end_index,omitempty"` -} - -// ResponsesWebSearchAction Web 搜索动作 -type ResponsesWebSearchAction struct { - Type string `json:"type"` - Query string `json:"query,omitempty"` - URL string `json:"url,omitempty"` - Pattern string `json:"pattern,omitempty"` - Sources []ResponsesWebSearchSource `json:"sources,omitempty"` -} - -// ResponsesWebSearchSource Web 搜索来源 -type ResponsesWebSearchSource struct { - Type string `json:"type"` - URL string `json:"url,omitempty"` -} - -// ResponsesFileSearchResult 文件搜索结果项 -type ResponsesFileSearchResult struct { - FileID string `json:"file_id"` - Text string `json:"text"` - Score float64 `json:"score"` -} - -// ResponsesSafetyCheck 安全检查项 -type ResponsesSafetyCheck struct { - ID string `json:"id"` - Code string `json:"code"` - Message string `json:"message"` -} - -// ResponsesCodeInterpreterOutput 代码解释器输出 -type ResponsesCodeInterpreterOutput struct { - Type string `json:"type"` - Logs string `json:"logs,omitempty"` - URL string `json:"url,omitempty"` -} - -// ResponsesShellAction Shell 命令动作 -type ResponsesShellAction struct { - Type string `json:"type"` - Command []string `json:"command,omitempty"` - MaxOutputLength *int `json:"max_output_length,omitempty"` - Timeout *int `json:"timeout,omitempty"` - Env map[string]string `json:"env,omitempty"` - User string `json:"user,omitempty"` - WorkingDirectory string `json:"working_directory,omitempty"` -} - -// ResponsesPatchAction 补丁动作 -type ResponsesPatchAction struct { - Type string `json:"type"` - Path string `json:"path,omitempty"` - Patch string `json:"patch,omitempty"` -} - -// ResponsesMCPTool MCP 工具描述 -type ResponsesMCPTool struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema any `json:"inputSchema,omitempty"` - Annotations any `json:"annotations,omitempty"` -} - -// ========== Responses API 流式响应 ========== - -// ResponsesStreamResponse 流式事件包装 -type ResponsesStreamResponse struct { - Type string `json:"type"` - Response *OpenAIResponsesResponse `json:"response,omitempty"` - Delta string `json:"delta,omitempty"` - Item *ResponsesOutput `json:"item,omitempty"` - OutputIndex *int `json:"output_index,omitempty"` - ContentIndex *int `json:"content_index,omitempty"` - SummaryIndex *int `json:"summary_index,omitempty"` - ItemID string `json:"item_id,omitempty"` - Part *ResponsesSummaryPart `json:"part,omitempty"` -} - -// ResponsesSummaryPart 推理摘要部分 -type ResponsesSummaryPart struct { - Type string `json:"type"` - Text string `json:"text"` -} - -// HasError 检查响应是否包含错误 -func (r *OpenAIResponsesResponse) HasError() bool { - if r.Error == nil { - return false - } - return true -} +type ResponsesStreamResponse = relaykitdto.ResponsesStreamResponse +type ResponsesSummaryPart = relaykitdto.ResponsesSummaryPart diff --git a/relay/dto/relaykit_types.go b/relay/dto/relaykit_types.go index 4ba2f43..8ee4203 100644 --- a/relay/dto/relaykit_types.go +++ b/relay/dto/relaykit_types.go @@ -75,16 +75,7 @@ type GeminiImageParameters = relaykitdto.GeminiImageParameters type GeminiImageResponse = relaykitdto.GeminiImageResponse type GeminiImagePrediction = relaykitdto.GeminiImagePrediction -// ==================== Coze / Dify / Ollama 类型别名 ==================== - -type CozeCreateRequest = relaykitdto.CozeCreateRequest -type CozeMessage = relaykitdto.CozeMessage - -type DifyRequest = relaykitdto.DifyRequest -type DifyUsage = relaykitdto.DifyUsage -type DifyBlockingResponse = relaykitdto.DifyBlockingResponse -type DifyMeta = relaykitdto.DifyMeta -type DifyStreamEvent = relaykitdto.DifyStreamEvent +// ==================== Ollama 类型别名 ==================== type OllamaChatRequest = relaykitdto.OllamaChatRequest type OllamaMessage = relaykitdto.OllamaMessage diff --git a/relay/handler/chat_handler.go b/relay/handler/chat_handler.go index cc344db..b7fb730 100644 --- a/relay/handler/chat_handler.go +++ b/relay/handler/chat_handler.go @@ -171,11 +171,12 @@ func WriteRelayError(w http.ResponseWriter, err error) { w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitErr.ResetAt)) } else if errors.As(err, &relayErr) { statusCode = relayErr.StatusCode - errMsg = relayErr.Message + // 上游错误的 Message 是上游响应体原文,解包出可读消息再暴露给客户端 + errMsg = helper.UnwrapUpstreamErrorMessage(relayErr.Message) if relayErr.Cause != nil { // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 - errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) + errMsg = errMsg + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } errType = relayErr.Type } diff --git a/relay/handler/claude_handler.go b/relay/handler/claude_handler.go index 8c57bef..7eadc3d 100644 --- a/relay/handler/claude_handler.go +++ b/relay/handler/claude_handler.go @@ -47,11 +47,12 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) { errType = "rate_limit_error" } else if errors.As(err, &relayErr) { statusCode = relayErr.StatusCode - errMsg = relayErr.Message + // 上游错误的 Message 是上游响应体原文,解包出可读消息再暴露给客户端 + errMsg = helper.UnwrapUpstreamErrorMessage(relayErr.Message) if relayErr.Cause != nil { // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 - errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) + errMsg = errMsg + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } errType = relayErr.Type } diff --git a/relay/handler/e2e_relay_stream_test.go b/relay/handler/e2e_relay_stream_test.go new file mode 100644 index 0000000..6267f09 --- /dev/null +++ b/relay/handler/e2e_relay_stream_test.go @@ -0,0 +1,444 @@ +package handler + +// e2e_relay_stream_test.go — 端到端流式与错误链路(假上游)。 +// +// 与 e2e_relay_test.go 共用驱动(runE2E),覆盖非流式测不到的两类性质: +// - 流式终止语义:各客户端协议的收尾契约互不相同,写错会让官方 SDK 直接报错 +// (Gemini 客户端多发一个 [DONE] 会让 @google/genai 的 JSON.parse 抛 SyntaxError, +// Responses 客户端缺 response.completed 会让 codex 一直挂起等待); +// - 错误映射:上游故障时客户端必须收到**入站协议的原生错误格式**, +// 而不是网关统一 JSON 或另一种协议的错误体。 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/constant" +) + +// ---------- 流式上游语料 ---------- + +var e2eUpstreamStreams = map[constant.RelayFormat]string{ + constant.RelayFormatOpenAI: "data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi there!\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chatcmpl-up\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"upstream-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":7,\"total_tokens\":19}}\n\n" + + "data: [DONE]\n\n", + + constant.RelayFormatClaude: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_up\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"upstream-model\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":1}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi there!\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + + constant.RelayFormatGemini: "data: {\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi there!\"}]}}]}\n\n" + + "data: {\"candidates\":[{\"index\":0,\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":12,\"candidatesTokenCount\":7,\"totalTokenCount\":19}}\n\n", + + constant.RelayFormatResponses: "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_up\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"in_progress\",\"model\":\"upstream-model\"}}\n\n" + + "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_up\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hi there!\"}\n\n" + + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_up\",\"object\":\"response\",\"created_at\":1700000000,\"status\":\"completed\",\"model\":\"upstream-model\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7,\"total_tokens\":19}}}\n\n" + + "data: [DONE]\n\n", + + constant.RelayFormatOllama: "{\"model\":\"upstream-model\",\"created_at\":\"2026-01-01T00:00:00Z\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi there!\"},\"done\":false}\n" + + "{\"model\":\"upstream-model\",\"created_at\":\"2026-01-01T00:00:01Z\",\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"done_reason\":\"stop\",\"prompt_eval_count\":12,\"eval_count\":7}\n", +} + +// ---------- SSE 解析 ---------- + +type sseFrame struct { + event string + data string +} + +// parseSSE 把客户端收到的字节流拆成帧(按空行分隔,聚合 event/data 行)。 +func parseSSE(raw string) []sseFrame { + var frames []sseFrame + for _, block := range strings.Split(raw, "\n\n") { + block = strings.TrimSpace(block) + if block == "" { + continue + } + var f sseFrame + var dataLines []string + for _, line := range strings.Split(block, "\n") { + switch { + case strings.HasPrefix(line, "event:"): + f.event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + if len(dataLines) == 0 && f.event == "" { + continue // 保活注释等非事件块 + } + f.data = strings.Join(dataLines, "\n") + frames = append(frames, f) + } + return frames +} + +func eventNames(frames []sseFrame) []string { + out := make([]string, 0, len(frames)) + for _, f := range frames { + out = append(out, f.event) + } + return out +} + +// ---------- 流式方向矩阵 ---------- + +func e2eStreamCases() []e2eCase { + mk := func(name string, provider constant.ProviderType, inbound constant.RelayFormat, + mode constant.RelayMode, upstreamFormat constant.RelayFormat) e2eCase { + return e2eCase{ + name: name, + provider: provider, + inbound: inbound, + mode: mode, + stream: true, + clientBody: e2eClientRequests[inbound], + wantUpstreamFormat: upstreamFormat, + upstreamContentType: "text/event-stream", + upstreamBody: e2eUpstreamStreams[upstreamFormat], + } + } + + return []e2eCase{ + // OpenAI 客户端(收尾需 [DONE]) + mk("openai客户端←claude上游", constant.ProviderClaude, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatClaude), + mk("openai客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatGemini), + mk("openai客户端←ollama上游", constant.ProviderOllama, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatOllama), + + // Claude 客户端(收尾需 message_stop,禁 [DONE]) + mk("claude客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatClaude, + constant.RelayModeClaudeMessages, constant.RelayFormatOpenAI), + mk("claude客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatClaude, + constant.RelayModeClaudeMessages, constant.RelayFormatGemini), + + // Gemini 客户端(收尾禁 [DONE]——官方 SDK 对每个 data 帧 JSON.parse) + mk("gemini客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatGemini, + constant.RelayModeGeminiChat, constant.RelayFormatOpenAI), + mk("gemini客户端←claude上游", constant.ProviderClaude, constant.RelayFormatGemini, + constant.RelayModeGeminiChat, constant.RelayFormatClaude), + + // Responses 客户端(收尾需 response.completed) + mk("responses客户端←openai上游", constant.ProviderOpenAI, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatOpenAI), + mk("responses客户端←claude上游", constant.ProviderClaude, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatClaude), + mk("responses客户端←gemini上游", constant.ProviderGemini, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatGemini), + } +} + +// TestE2E_StreamTerminalSemantics 断言 3(流式闭环):客户端收到的 SSE 必须符合 +// 入站协议的收尾契约,且每个 data 帧都是可解析的 JSON、用量可被计费捕获。 +func TestE2E_StreamTerminalSemantics(t *testing.T) { + for _, c := range e2eStreamCases() { + t.Run(c.name, func(t *testing.T) { + res := runE2E(t, c) + if res.err != nil { + t.Fatalf("链路失败: %v", res.err) + } + + frames := parseSSE(res.clientBody) + if len(frames) == 0 { + t.Fatalf("客户端未收到任何 SSE 帧\n原始输出: %s", truncate([]byte(res.clientBody))) + } + + // 每个 data 帧必须是合法 JSON([DONE] 哨兵除外): + // 写出空 data 或畸形 JSON 会让客户端 SDK 解析失败并中止整条请求, + // 而网关侧只会看到 ctx 取消、记成 client_gone(问题被掩盖) + hasDone := false + for i, f := range frames { + if f.data == "[DONE]" { + hasDone = true + continue + } + if f.data == "" { + t.Errorf("第 %d 帧(event=%q)的 data 为空——客户端 JSON.parse(\"\") 会抛错", i, f.event) + continue + } + var probe any + if err := json.Unmarshal([]byte(f.data), &probe); err != nil { + t.Errorf("第 %d 帧(event=%q)的 data 不是合法 JSON: %v\n内容: %s", i, f.event, err, truncate([]byte(f.data))) + } + } + + // 收尾契约按客户端协议区分 + switch c.inbound { + case constant.RelayFormatOpenAI: + if !hasDone { + t.Error("OpenAI 客户端流缺少 data: [DONE] 收尾哨兵") + } + case constant.RelayFormatGemini: + if hasDone { + t.Error("Gemini 客户端流写出了 [DONE]——官方 SDK(@google/genai)对每个 data 帧做 JSON.parse,会抛 SyntaxError") + } + if !strings.Contains(res.clientBody, "finishReason") { + t.Errorf("Gemini 客户端流缺少带 finishReason 的终止帧\n原始输出: %s", truncate([]byte(res.clientBody))) + } + case constant.RelayFormatClaude: + if hasDone { + t.Error("Claude 客户端流写出了 [DONE]——Claude 协议以 message_stop 事件收尾,不使用该哨兵") + } + if !containsEvent(frames, "message_stop") { + t.Errorf("Claude 客户端流缺少 message_stop 终止事件(客户端会一直等待)\n事件序列: %v", eventNames(frames)) + } + case constant.RelayFormatResponses: + if !containsEvent(frames, "response.completed") { + t.Errorf("Responses 客户端流缺少 response.completed 终止事件(codex 等客户端会挂起)\n事件序列: %v", eventNames(frames)) + } + } + + if !strings.Contains(res.clientBody, "Hi there!") { + t.Errorf("上游回答内容在流式转换中丢失\n原始输出: %s", truncate([]byte(res.clientBody))) + } + + // 计费闭环 + if res.usage == nil { + t.Fatal("流式未返回用量(计费将按 0 token 结算)") + } + if res.usage.PromptTokens == 0 || res.usage.CompletionTokens == 0 { + t.Errorf("流式用量提取不完整: prompt=%d completion=%d(上游语料为 12/7)", + res.usage.PromptTokens, res.usage.CompletionTokens) + } + }) + } +} + +func containsEvent(frames []sseFrame, name string) bool { + for _, f := range frames { + if f.event == name { + return true + } + // 无 event 行时退回按 data 里的 type 字段判定(部分方向以纯 data 帧承载事件) + if f.event == "" && strings.Contains(f.data, `"type":"`+name+`"`) { + return true + } + } + return false +} + +// ---------- 错误映射 ---------- + +// errorShapeCheck 各入站协议的原生错误体判据:客户端必须拿到自己协议的错误格式, +// 而不是网关统一 JSON、也不是另一种协议的错误体(SDK 会解析失败)。 +var errorShapeCheck = map[constant.RelayFormat]func(t *testing.T, body string){ + constant.RelayFormatOpenAI: func(t *testing.T, body string) { + t.Helper() + var obj struct { + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil { + t.Errorf("OpenAI 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body))) + } + }, + constant.RelayFormatClaude: func(t *testing.T, body string) { + t.Helper() + var obj struct { + Type string `json:"type"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil || obj.Type != "error" { + t.Errorf("Claude 客户端应收到 {\"type\":\"error\",\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", + err, truncate([]byte(body))) + } + }, + constant.RelayFormatGemini: func(t *testing.T, body string) { + t.Helper() + // Gemini 错误体为 {"error":{"code","message","status"}}(也接受数组包装形态) + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "[") { + trimmed = strings.TrimSuffix(strings.TrimPrefix(trimmed, "["), "]") + } + var obj struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(trimmed), &obj); err != nil || obj.Error == nil { + t.Errorf("Gemini 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body))) + } + }, + constant.RelayFormatResponses: func(t *testing.T, body string) { + t.Helper() + var obj struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(body), &obj); err != nil || obj.Error == nil { + t.Errorf("Responses 客户端应收到 {\"error\":{...}} 形态的错误体(err=%v)\n实际内容: %s", err, truncate([]byte(body))) + } + }, +} + +// hostErrorWriterFor 返回宿主按入站格式分派的错误写出器 +// (与 internal/handler/relay 的真实接线一致:/v1/messages → Claude、 +// /v1beta/* → Gemini、chat 与 responses → OpenAI)。 +func hostErrorWriterFor(inbound constant.RelayFormat) func(http.ResponseWriter, error) { + switch inbound { + case constant.RelayFormatClaude: + return WriteClaudeRelayError + case constant.RelayFormatGemini: + return WriteGeminiRelayError + default: + return WriteRelayError + } +} + +// TestE2E_UpstreamErrorMapping 断言 4(错误映射):上游返回 4xx/5xx 时, +// 客户端最终收到的必须是入站协议的原生错误格式,且链路向上报错 +// (供调度按上游故障扣健康分 / 换渠道)。 +// +// 两种写出路径都要覆盖,否则测试会在「adaptor 没写、宿主才写」的方向上静默空过: +// - adaptor 内写出(置 ResponseWritten):直接校验其字节; +// - adaptor 只上抛(未写出):按宿主真实接线调用 Write*RelayError 再校验。 +// +// 两条路径的判定结果也一并断言(expectAdaptorWrites),行为漂移时立即可见。 +func TestE2E_UpstreamErrorMapping(t *testing.T) { + upstreamErrBodies := map[constant.RelayFormat]string{ + constant.RelayFormatOpenAI: `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit"}}`, + constant.RelayFormatClaude: `{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"}}`, + constant.RelayFormatGemini: `{"error":{"code":429,"message":"rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}`, + } + + // 当前各方向的错误写出归属:OpenAI 上游的三个入站方向由 adaptor 就地改写为 + // 入站协议错误体(置 ResponseWritten,上层不再重写、也不换渠道重试); + // Claude/Gemini 上游方向只上抛错误,由宿主统一写出(保留换渠道重试的可能)。 + expectAdaptorWrites := map[string]bool{ + "claude→openai": true, + "gemini→openai": true, + "responses→openai": true, + } + + for _, c := range e2eNonStreamCases() { + // 同格式方向不参与:上游错误原样透传,没有跨协议映射行为可测 + //(openai→openai / claude→claude 直连基线、gemini→vertex(gemini) 等) + if c.inbound == c.wantUpstreamFormat { + continue + } + errBody, ok := upstreamErrBodies[c.wantUpstreamFormat] + if !ok { + continue // 上游格式无错误语料(ollama 等) + } + + t.Run(c.name, func(t *testing.T) { + ec := c + ec.upstreamStatus = http.StatusTooManyRequests + ec.upstreamBody = errBody + res := runE2E(t, ec) + + // 上游故障必须向上报错:吞掉错误会让调度器把故障渠道判成健康 + if res.err == nil { + t.Fatal("上游 429 未向上返回错误(调度器无法据此扣健康分 / 换渠道)") + } + + adaptorWrote := res.clientBody != "" + if want := expectAdaptorWrites[c.name]; adaptorWrote != want { + t.Errorf("错误写出归属变化:adaptor 写出 = %v,期望 %v\n"+ + "→ 若为有意变更请更新 expectAdaptorWrites;否则可能是 ResponseWritten 标记漏置/误置"+ + "(漏置导致错误体被写两次,误置导致本可重试的请求不再换渠道)", adaptorWrote, want) + } + + // 取客户端最终可见的字节:adaptor 已写出就用它,否则按宿主接线补写 + clientBody := res.clientBody + if !adaptorWrote { + rec := httptest.NewRecorder() + hostErrorWriterFor(c.inbound)(rec, res.err) + clientBody = rec.Body.String() + if clientBody == "" { + t.Fatal("adaptor 与宿主都没有写出错误体,客户端将收到空响应") + } + } + + if check, ok := errorShapeCheck[c.inbound]; ok { + check(t, clientBody) + } + // 上游的人类可读错误消息必须传达到客户端,而不是被替换成通用文案或整坨转义 JSON + if !strings.Contains(clientBody, "rate limit exceeded") { + t.Errorf("上游错误消息在映射中丢失\n实际内容: %s", truncate([]byte(clientBody))) + } + if strings.Contains(clientBody, `\"error\"`) { + t.Errorf("错误体被双重编码(上游 JSON 原文被塞进 message 字段),客户端拿到的是一坨转义 JSON 而非可读消息\n实际内容: %s", + truncate([]byte(clientBody))) + } + }) + } +} + +// TestE2E_ClaudeInboundErrorTypeMapping Claude 入站错误类型映射: +// error.type 是 Anthropic SDK 的分类与退避依据,必须按上游真实错误类型/状态码映射, +// 不能一律 api_error(限流被当成服务端故障会让客户端退避策略失效)。 +func TestE2E_ClaudeInboundErrorTypeMapping(t *testing.T) { + cases := []struct { + name string + upstreamStatus int + upstreamBody string + wantType string + }{ + {"限流", http.StatusTooManyRequests, + `{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}`, "rate_limit_error"}, + {"鉴权失败", http.StatusUnauthorized, + `{"error":{"message":"invalid api key","type":"authentication_error"}}`, "authentication_error"}, + {"参数非法", http.StatusBadRequest, + `{"error":{"message":"bad param","type":"invalid_request_error"}}`, "invalid_request_error"}, + {"服务端故障", http.StatusInternalServerError, + `{"error":{"message":"boom","type":"server_error"}}`, "api_error"}, + // 上游未给出 type:按状态码兜底,仍优于一律 api_error + {"无类型按状态码兜底", http.StatusTooManyRequests, + `{"error":{"message":"slow down"}}`, "rate_limit_error"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := runE2E(t, e2eCase{ + provider: constant.ProviderOpenAI, + inbound: constant.RelayFormatClaude, + mode: constant.RelayModeClaudeMessages, + clientBody: e2eClientRequests[constant.RelayFormatClaude], + upstreamStatus: tc.upstreamStatus, + upstreamBody: tc.upstreamBody, + }) + if res.clientBody == "" { + t.Fatal("adaptor 未写出 Claude 错误体") + } + + var obj struct { + Type string `json:"type"` + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(res.clientBody), &obj); err != nil { + t.Fatalf("错误体不是合法 JSON: %v\n内容: %s", err, res.clientBody) + } + if obj.Type != "error" { + t.Errorf("顶层 type = %q,期望 \"error\"", obj.Type) + } + if obj.Error.Type != tc.wantType { + t.Errorf("error.type = %q,期望 %q(SDK 据此分类与退避)", obj.Error.Type, tc.wantType) + } + if strings.HasPrefix(obj.Error.Message, "{") { + t.Errorf("error.message 是 JSON 原文而非可读消息: %s", obj.Error.Message) + } + if res.clientStatus != tc.upstreamStatus { + t.Errorf("客户端状态码 = %d,期望 %d", res.clientStatus, tc.upstreamStatus) + } + }) + } +} diff --git a/relay/handler/e2e_relay_test.go b/relay/handler/e2e_relay_test.go new file mode 100644 index 0000000..5938b2d --- /dev/null +++ b/relay/handler/e2e_relay_test.go @@ -0,0 +1,523 @@ +package handler + +// e2e_relay_test.go — 协议代理链路的端到端集成测试(假上游)。 +// +// 为什么需要这一层:既有三层防线(wire 保真 / 能力守恒 / 金样本)与矩阵守卫都是 +// 静态或单元维度的——分别验证「DTO 形状对不对」「转换器输出对不对」「该不该转、 +// URL 拿不拿得到」。但没有任何测试走过完整链路,于是「每个单元各自正确、缝隙处错配」 +// 的问题能逃过全部测试:典型如 vertex(URL 不报错但端点吃的是原生格式,矩阵却发 chat 体) +// 与 GeminiChat mode 漏配(转换器和响应桥都就位、DoRequest 处 0ms 断链)。 +// +// 本文件用 httptest 假上游驱动真实链路: +// +// convertRequestBody → adaptor.DoRequest → 假上游(捕获 URL/Header/Body) +// → 返回上游格式响应 → adaptor.DoResponse → 客户端可见字节 +// +// 断言三条单元测试给不了的性质: +// 1. 体格式 = 端点格式:上游收到的请求体必须符合该端点期望的协议格式; +// 2. 响应闭环:客户端最终收到的字节必须符合入站协议的 wire 契约(含流式终止语义); +// 3. 错误映射:上游故障时客户端拿到的是入站协议的原生错误格式。 + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/qianfree/team-api/relay/channel" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// ---------- 假上游 ---------- + +// fakeUpstream 捕获网关实际发出的请求,并按配置返回响应。 +type fakeUpstream struct { + srv *httptest.Server + + mu sync.Mutex + gotPath string + gotQuery string + gotBody []byte + gotHeaders http.Header + gotCount int + + status int + contentType string + body string +} + +func newFakeUpstream(status int, contentType, body string) *fakeUpstream { + up := &fakeUpstream{status: status, contentType: contentType, body: body} + up.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + + up.mu.Lock() + up.gotPath = r.URL.Path + up.gotQuery = r.URL.RawQuery + up.gotBody = raw + up.gotHeaders = r.Header.Clone() + up.gotCount++ + status, ct, body := up.status, up.contentType, up.body + up.mu.Unlock() + + w.Header().Set("Content-Type", ct) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + })) + return up +} + +func (u *fakeUpstream) close() { u.srv.Close() } + +func (u *fakeUpstream) request() (path, query string, body []byte, headers http.Header, count int) { + u.mu.Lock() + defer u.mu.Unlock() + return u.gotPath, u.gotQuery, u.gotBody, u.gotHeaders, u.gotCount +} + +// ---------- 链路驱动 ---------- + +type e2eCase struct { + name string + provider constant.ProviderType + inbound constant.RelayFormat + mode constant.RelayMode + stream bool + + // 渠道能力开关(Responses 桥接方向用) + chatViaResponses bool + supportsResponses bool + + // upstreamModel 覆盖上游模型名(默认 "upstream-model")。 + // 模型名驱动协议的上游(Vertex:含 "claude" 走 Anthropic 端点、否则走 Gemini 端点) + // 必须显式指定,否则测不出该渠道的协议分流。 + upstreamModel string + + clientBody string + + // 上游侧期望 + wantUpstreamFormat constant.RelayFormat + wantPathContains string + // wantUpstreamShape 覆盖按 wantUpstreamFormat 查表得到的结构签名。 + // 供应商私有后处理会让体偏离标准格式时使用(如 Vertex 的 Anthropic 端点)。 + wantUpstreamShape *shapeSpec + + // 假上游返回 + upstreamStatus int + upstreamContentType string + upstreamBody string +} + +type e2eResult struct { + upstream *fakeUpstream + clientStatus int + clientBody string + clientHeader http.Header + usage *common.Usage + err error +} + +// runE2E 驱动一次完整代理链路,返回上游侧与客户端侧的可观测结果。 +func runE2E(t *testing.T, c e2eCase) e2eResult { + t.Helper() + + ct := c.upstreamContentType + if ct == "" { + ct = "application/json" + } + status := c.upstreamStatus + if status == 0 { + status = http.StatusOK + } + up := newFakeUpstream(status, ct, c.upstreamBody) + t.Cleanup(up.close) + + upstreamModel := c.upstreamModel + if upstreamModel == "" { + upstreamModel = "upstream-model" + } + + info := &common.RelayInfo{ + Context: context.Background(), + RequestID: "e2e-req-1", + RelayMode: int(c.mode), + IsStream: c.stream, + OriginModelName: "origin-model", + InboundFormat: c.inbound, + ClientFormat: c.inbound, + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelID: 1, + ChannelType: int(c.provider), + ChannelName: "e2e-channel", + BaseURL: up.srv.URL, + ApiKey: "sk-e2e-test", + UpstreamModelName: upstreamModel, + ChatViaResponses: c.chatViaResponses, + SupportsResponses: c.supportsResponses, + }, + } + // chat_via_responses 渠道的 chat 入站:由 relay_handler 置位,此处按真实链路模拟 + if c.chatViaResponses && c.mode == constant.RelayModeChatCompletions { + info.UseResponsesAPI = true + } + + ctx := context.Background() + adaptor := channel.GetAdaptor(int(c.provider)) + if adaptor == nil { + t.Fatalf("provider %d 没有注册 adaptor", c.provider) + } + adaptor.Init(info) + + reqBody, err := convertRequestBody(ctx, info, []byte(c.clientBody), adaptor) + if err != nil { + return e2eResult{upstream: up, err: err} + } + + resp, err := adaptor.DoRequest(ctx, info, reqBody) + if err != nil { + return e2eResult{upstream: up, err: err} + } + + rec := httptest.NewRecorder() + usage, err := adaptor.DoResponse(ctx, resp, info, rec) + + return e2eResult{ + upstream: up, + clientStatus: rec.Code, + clientBody: rec.Body.String(), + clientHeader: rec.Header(), + usage: usage, + err: err, + } +} + +// ---------- 格式签名断言 ---------- + +// shapeSpec 一种请求/响应体的结构签名:required 为必须存在的顶层 key, +// forbidden 为「其他格式独有、本形态绝不该出现」的顶层 key。 +// 体与端点错配时(如把 chat 体发到 Gemini 的 :generateContent),forbidden 必然命中。 +type shapeSpec struct { + required []string + forbidden []string +} + +// requestSignature 各协议格式请求体的结构签名。 +var requestSignature = map[constant.RelayFormat]shapeSpec{ + constant.RelayFormatOpenAI: { + required: []string{"model", "messages"}, + forbidden: []string{"contents", "generationConfig", "input", "system"}, + }, + constant.RelayFormatClaude: { + required: []string{"model", "messages", "max_tokens"}, + forbidden: []string{"contents", "generationConfig", "input", "stream_options"}, + }, + constant.RelayFormatGemini: { + required: []string{"contents"}, + forbidden: []string{"messages", "input", "max_tokens", "system"}, + }, + constant.RelayFormatResponses: { + required: []string{"model", "input"}, + forbidden: []string{"messages", "contents", "generationConfig"}, + }, + constant.RelayFormatOllama: { + required: []string{"model", "messages"}, + forbidden: []string{"contents", "input", "max_tokens", "system"}, + }, +} + +// vertexClaudeShape Vertex AI 的 Anthropic 端点(rawPredict)请求体形态: +// 标准 Claude 体去掉 model(模型由 URL 指定)、加上 anthropic_version(必填)。 +var vertexClaudeShape = shapeSpec{ + required: []string{"messages", "max_tokens", "anthropic_version"}, + forbidden: []string{"model", "contents", "generationConfig", "input"}, +} + +// responseSignature 各协议格式响应体的结构签名(客户端可见字节)。 +var responseSignature = map[constant.RelayFormat]shapeSpec{ + constant.RelayFormatOpenAI: { + required: []string{"choices"}, + forbidden: []string{"candidates", "output", "content"}, + }, + constant.RelayFormatClaude: { + required: []string{"type", "content"}, + forbidden: []string{"choices", "candidates", "output"}, + }, + constant.RelayFormatGemini: { + required: []string{"candidates"}, + forbidden: []string{"choices", "output", "content"}, + }, + constant.RelayFormatResponses: { + required: []string{"output", "status"}, + forbidden: []string{"choices", "candidates"}, + }, +} + +func assertShape(t *testing.T, what string, label string, sig shapeSpec, body []byte) { + t.Helper() + + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err != nil { + t.Fatalf("%s: 不是合法 JSON 对象(期望 %s 形态): %v\n实际内容: %s", what, label, err, truncate(body)) + } + + for _, k := range sig.required { + if _, exists := obj[k]; !exists { + t.Errorf("%s: 期望 %s 形态,但缺少必需字段 %q\n实际内容: %s", what, label, k, truncate(body)) + } + } + for _, k := range sig.forbidden { + if _, exists := obj[k]; exists { + t.Errorf("%s: 期望 %s 形态,却出现了不该有的字段 %q——体与端点错配\n实际内容: %s", + what, label, k, truncate(body)) + } + } +} + +func assertTopLevelShape(t *testing.T, what string, format constant.RelayFormat, body []byte, + sigs map[constant.RelayFormat]shapeSpec) { + t.Helper() + + sig, ok := sigs[format] + if !ok { + t.Fatalf("%s: 格式 %s 没有登记结构签名", what, format) + } + assertShape(t, what, string(format), sig, body) +} + +func truncate(b []byte) string { + const max = 600 + if len(b) <= max { + return string(b) + } + return string(b[:max]) + "...(truncated)" +} + +// ---------- 语料 ---------- + +// 各入站格式的客户端请求(均带 system 提示词:它在 OpenAI 侧进 messages、 +// 在 Claude 侧升为顶层 system,是两种相似结构的关键判别点)。 +var e2eClientRequests = map[constant.RelayFormat]string{ + constant.RelayFormatOpenAI: `{ + "model": "origin-model", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"} + ], + "max_tokens": 1024, + "temperature": 0.7 + }`, + constant.RelayFormatClaude: `{ + "model": "origin-model", + "system": "You are helpful.", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 1024 + }`, + constant.RelayFormatGemini: `{ + "systemInstruction": {"parts": [{"text": "You are helpful."}]}, + "contents": [{"role": "user", "parts": [{"text": "Hello"}]}], + "generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7} + }`, + constant.RelayFormatResponses: `{ + "model": "origin-model", + "instructions": "You are helpful.", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]}], + "max_output_tokens": 1024 + }`, +} + +// 各上游格式的非流式响应语料(含用量,供计费闭环断言)。 +var e2eUpstreamResponses = map[constant.RelayFormat]string{ + constant.RelayFormatOpenAI: `{ + "id": "chatcmpl-up", "object": "chat.completion", "created": 1700000000, "model": "upstream-model", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "Hi there!"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 12, "completion_tokens": 7, "total_tokens": 19} + }`, + constant.RelayFormatClaude: `{ + "id": "msg_up", "type": "message", "role": "assistant", "model": "upstream-model", + "content": [{"type": "text", "text": "Hi there!"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 12, "output_tokens": 7} + }`, + constant.RelayFormatGemini: `{ + "candidates": [{"index": 0, "content": {"role": "model", "parts": [{"text": "Hi there!"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 7, "totalTokenCount": 19} + }`, + constant.RelayFormatResponses: `{ + "id": "resp_up", "object": "response", "created_at": 1700000000, "status": "completed", "model": "upstream-model", + "output": [{"type": "message", "id": "msg_up", "status": "completed", "role": "assistant", + "content": [{"type": "output_text", "text": "Hi there!"}]}], + "usage": {"input_tokens": 12, "output_tokens": 7, "total_tokens": 19} + }`, + constant.RelayFormatOllama: `{ + "model": "upstream-model", "created_at": "2026-01-01T00:00:00Z", + "message": {"role": "assistant", "content": "Hi there!"}, + "done": true, "done_reason": "stop", + "prompt_eval_count": 12, "eval_count": 7 + }`, +} + +// ---------- 非流式方向矩阵 ---------- + +// e2eNonStreamCases 覆盖全部已注册转换方向的真实链路(含同格式直连基线)。 +func e2eNonStreamCases() []e2eCase { + mk := func(name string, provider constant.ProviderType, inbound constant.RelayFormat, + mode constant.RelayMode, upstreamFormat constant.RelayFormat, pathContains string) e2eCase { + return e2eCase{ + name: name, + provider: provider, + inbound: inbound, + mode: mode, + clientBody: e2eClientRequests[inbound], + wantUpstreamFormat: upstreamFormat, + wantPathContains: pathContains, + upstreamBody: e2eUpstreamResponses[upstreamFormat], + } + } + + return []e2eCase{ + // OpenAI 入站 → 各原生上游 + mk("openai→claude", constant.ProviderClaude, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatClaude, "/v1/messages"), + mk("openai→gemini", constant.ProviderGemini, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatGemini, ":generateContent"), + mk("openai→ollama", constant.ProviderOllama, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatOllama, "/api/chat"), + + // 非 OpenAI 入站 → OpenAI 上游(反向) + mk("claude→openai", constant.ProviderOpenAI, constant.RelayFormatClaude, + constant.RelayModeClaudeMessages, constant.RelayFormatOpenAI, "/v1/chat/completions"), + mk("gemini→openai", constant.ProviderOpenAI, constant.RelayFormatGemini, + constant.RelayModeGeminiChat, constant.RelayFormatOpenAI, "/v1/chat/completions"), + mk("responses→openai", constant.ProviderOpenAI, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatOpenAI, "/v1/chat/completions"), + + // 跨原生方向 + mk("claude→gemini", constant.ProviderGemini, constant.RelayFormatClaude, + constant.RelayModeClaudeMessages, constant.RelayFormatGemini, ":generateContent"), + mk("gemini→claude", constant.ProviderClaude, constant.RelayFormatGemini, + constant.RelayModeGeminiChat, constant.RelayFormatClaude, "/v1/messages"), + mk("responses→claude", constant.ProviderClaude, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatClaude, "/v1/messages"), + mk("responses→gemini", constant.ProviderGemini, constant.RelayFormatResponses, + constant.RelayModeResponses, constant.RelayFormatGemini, ":generateContent"), + + // 同格式直连基线(矩阵不接管,走 adaptor 原生路径) + mk("openai→openai 直连", constant.ProviderOpenAI, constant.RelayFormatOpenAI, + constant.RelayModeChatCompletions, constant.RelayFormatOpenAI, "/v1/chat/completions"), + mk("claude→claude 直连", constant.ProviderClaude, constant.RelayFormatClaude, + constant.RelayModeClaudeMessages, constant.RelayFormatClaude, "/v1/messages"), + + // Vertex AI:模型名驱动的双协议上游。协议判定必须跟着模型走, + // 否则矩阵按渠道类型判成 openai、把 chat 体发到 Gemini/Anthropic 原生端点。 + vertexCase("openai→vertex(gemini)", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions, "gemini-2.5-pro"), + vertexCase("claude→vertex(gemini)", constant.RelayFormatClaude, constant.RelayModeClaudeMessages, "gemini-2.5-pro"), + vertexCase("gemini→vertex(gemini) 同格式", constant.RelayFormatGemini, constant.RelayModeGeminiChat, "gemini-2.5-pro"), + vertexCase("responses→vertex(gemini)", constant.RelayFormatResponses, constant.RelayModeResponses, "gemini-2.5-pro"), + vertexCase("openai→vertex(claude)", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions, "claude-sonnet-4-5"), + vertexCase("claude→vertex(claude) 同格式", constant.RelayFormatClaude, constant.RelayModeClaudeMessages, "claude-sonnet-4-5"), + vertexCase("gemini→vertex(claude)", constant.RelayFormatGemini, constant.RelayModeGeminiChat, "claude-sonnet-4-5"), + } +} + +// vertexCase 构造 Vertex 渠道的 E2E 用例:按模型名推出上游协议、对应端点与体形态。 +// - Gemini 模型 → publishers/google/{model}:generateContent,标准 Gemini 体; +// - Claude 模型 → publishers/anthropic/{model}:rawPredict,Claude 体去 model、带 anthropic_version。 +func vertexCase(name string, inbound constant.RelayFormat, mode constant.RelayMode, model string) e2eCase { + c := e2eCase{ + name: name, + provider: constant.ProviderVertex, + inbound: inbound, + mode: mode, + upstreamModel: model, + clientBody: e2eClientRequests[inbound], + } + if strings.Contains(model, "claude") { + c.wantUpstreamFormat = constant.RelayFormatClaude + c.wantPathContains = "publishers/anthropic/models/" + model + ":rawPredict" + c.wantUpstreamShape = &vertexClaudeShape + c.upstreamBody = e2eUpstreamResponses[constant.RelayFormatClaude] + return c + } + c.wantUpstreamFormat = constant.RelayFormatGemini + c.wantPathContains = "publishers/google/models/" + model + ":generateContent" + c.upstreamBody = e2eUpstreamResponses[constant.RelayFormatGemini] + return c +} + +// TestE2E_RequestBodyMatchesEndpointFormat 断言 1(体格式 = 端点格式): +// 上游实际收到的请求体必须符合该端点期望的协议格式,且用户内容不丢。 +// +// 这是单元测试结构上够不到的性质——转换器输出正确、URL 计算正确,但两者配错时 +// 只有真实链路能发现(vertex 类问题的检测面)。 +func TestE2E_RequestBodyMatchesEndpointFormat(t *testing.T) { + for _, c := range e2eNonStreamCases() { + t.Run(c.name, func(t *testing.T) { + res := runE2E(t, c) + if res.err != nil { + t.Fatalf("链路失败: %v", res.err) + } + + path, query, body, _, count := res.upstream.request() + if count != 1 { + t.Fatalf("上游收到 %d 次请求,期望 1 次", count) + } + full := path + if query != "" { + full += "?" + query + } + if !strings.Contains(full, c.wantPathContains) { + t.Errorf("上游端点 = %q,期望包含 %q", full, c.wantPathContains) + } + + if c.wantUpstreamShape != nil { + assertShape(t, "上游请求体(端点 "+full+")", string(c.wantUpstreamFormat)+"@vertex", *c.wantUpstreamShape, body) + } else { + assertTopLevelShape(t, "上游请求体(端点 "+full+")", c.wantUpstreamFormat, body, requestSignature) + } + + if !strings.Contains(string(body), "Hello") { + t.Errorf("用户消息内容在转换链路中丢失\n实际内容: %s", truncate(body)) + } + }) + } +} + +// TestE2E_ResponseMatchesInboundFormat 断言 2(响应闭环): +// 客户端最终收到的字节必须符合入站协议的响应 wire 契约,内容与用量都不丢。 +func TestE2E_ResponseMatchesInboundFormat(t *testing.T) { + for _, c := range e2eNonStreamCases() { + t.Run(c.name, func(t *testing.T) { + res := runE2E(t, c) + if res.err != nil { + t.Fatalf("链路失败: %v", res.err) + } + if res.clientStatus != http.StatusOK { + t.Fatalf("客户端状态码 = %d,期望 200\n响应体: %s", res.clientStatus, truncate([]byte(res.clientBody))) + } + + assertTopLevelShape(t, "客户端响应体", c.inbound, []byte(res.clientBody), responseSignature) + + if !strings.Contains(res.clientBody, "Hi there!") { + t.Errorf("上游回答内容在响应转换中丢失\n实际内容: %s", truncate([]byte(res.clientBody))) + } + + // 计费闭环:用量必须被提取(否则整条请求按 0 token 结算) + if res.usage == nil { + t.Fatal("未返回用量(计费将按 0 token 结算)") + } + if res.usage.PromptTokens == 0 || res.usage.CompletionTokens == 0 { + t.Errorf("用量提取不完整: prompt=%d completion=%d(上游语料为 12/7)", + res.usage.PromptTokens, res.usage.CompletionTokens) + } + }) + } +} diff --git a/relay/handler/fuzz_test.go b/relay/handler/fuzz_test.go new file mode 100644 index 0000000..21366ff --- /dev/null +++ b/relay/handler/fuzz_test.go @@ -0,0 +1,182 @@ +package handler + +// fuzz_test.go — 宿主入站解析 + 矩阵裁决 + 转换的模糊测试。 +// +// 与 relaykit 侧的 fuzz(relayconvert/register/fuzz_test.go)覆盖面**不重叠**, +// 因为两者的输入形态不同: +// - relaykit 侧直接用类型化 DTO 驱动转换器; +// - 宿主 parseInboundRequest 是**裸 json.Unmarshal**,解析出的 message content +// 是 `[]any`(元素为 map[string]any)而非 `[]dto.ContentPart`。两种形态并存, +// 转换器须经 shared.NormalizeContentParts 归一——历史上多个静默能力丢失 bug +// (多模态文本+图片丢失、链式 ContentPart 丢失)正出在这条缝上。 +// +// 本目标按宿主真实路径构造输入: +// +// parseInboundRequest(宿主 DTO)→ 矩阵裁决 converterID → ConvertRequestByID → marshal +// +// 断言:不 panic;转换成功 ⇒ 结果可 json.Marshal(宿主紧接着就要序列化发上游)。 +// +// 不经 convertRequestViaRelaykit 外壳是有意为之:那层在转换失败时打 Warning 日志, +// 模糊测试每秒数十万次失败输入会产出海量日志、拖垮吞吐;被跳过的仅是日志与 +// 指标上报,协议面逻辑(解析形态、矩阵、转换、序列化)全部覆盖。 +// +// 运行方式: +// - 常规回归:go test ./relay/handler/ —— 只跑种子语料; +// - 主动模糊:go test -run XXX -fuzz FuzzHostInboundConversion ./relay/handler/ + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/relaykit_bridge" + "github.com/qianfree/team-api/relaykit/relayconvert" +) + +// fuzzInbound 宿主支持的四种文本入站(格式 + 对应 RelayMode)。 +var fuzzInbound = []struct { + format constant.RelayFormat + mode constant.RelayMode +}{ + {constant.RelayFormatOpenAI, constant.RelayModeChatCompletions}, + {constant.RelayFormatClaude, constant.RelayModeClaudeMessages}, + {constant.RelayFormatGemini, constant.RelayModeGeminiChat}, + {constant.RelayFormatResponses, constant.RelayModeResponses}, +} + +// fuzzProviders 代表性上游供应商,覆盖全部上游协议格式 +// (OpenAI / Claude / Gemini / Ollama),从而覆盖全部矩阵方向。 +var fuzzProviders = []constant.ProviderType{ + constant.ProviderOpenAI, + constant.ProviderClaude, + constant.ProviderGemini, + constant.ProviderOllama, +} + +func fuzzRelayInfo(provider constant.ProviderType, inbound constant.RelayFormat, mode constant.RelayMode) *common.RelayInfo { + return &common.RelayInfo{ + Context: context.Background(), + RequestID: "fuzz", + RelayMode: int(mode), + OriginModelName: "origin-model", + InboundFormat: inbound, + ClientFormat: inbound, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(provider), + BaseURL: "https://upstream.invalid", + ApiKey: "sk-fuzz", + UpstreamModelName: "upstream-model", + }, + } +} + +// FuzzParseInboundRequest 宿主入站解析:对任意字节都不得 panic。 +// 这是客户端可直接控制的第一道门——解析失败是合法结果(宿主据此 hard-fail 拒绝请求), +// 崩溃则不是。 +func FuzzParseInboundRequest(f *testing.F) { + for _, body := range fuzzHostSeeds { + for i := range fuzzInbound { + f.Add(i, body) + } + } + + f.Fuzz(func(t *testing.T, inboundIdx int, body string) { + if inboundIdx < 0 { + return + } + in := fuzzInbound[inboundIdx%len(fuzzInbound)] + + parsed, err := parseInboundRequest(in.format, []byte(body)) + if err != nil { + return // 解析失败是合法结果 + } + if parsed == nil { + t.Fatalf("parseInboundRequest(%s) 返回了 (nil, nil):调用方会把 nil 传进转换器", in.format) + } + }) +} + +// FuzzHostInboundConversion 宿主全路径:解析 → 矩阵裁决 → 转换 → 序列化。 +func FuzzHostInboundConversion(f *testing.F) { + for _, body := range fuzzHostSeeds { + for i := range fuzzInbound { + f.Add(i, 0, body) + } + } + + f.Fuzz(func(t *testing.T, inboundIdx, providerIdx int, body string) { + if inboundIdx < 0 || providerIdx < 0 { + return + } + in := fuzzInbound[inboundIdx%len(fuzzInbound)] + provider := fuzzProviders[providerIdx%len(fuzzProviders)] + + // 宿主真实解析路径:裸 unmarshal,content 为 []any(map) 形态 + parsed, err := parseInboundRequest(in.format, []byte(body)) + if err != nil || parsed == nil { + return + } + + info := fuzzRelayInfo(provider, in.format, in.mode) + upstream := relaykit_bridge.EffectiveUpstreamFormat(info) + converterID := relaykit_bridge.RequestConverterIDForRoute(in.format, upstream, info.RelayMode) + if converterID == "" { + return // 同格式直连或矩阵未覆盖,不经 relaykit + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + converted, err := relayconvert.ConvertRequestByID(ctx, info, converterID, parsed) + if err != nil { + return // 转换失败是合法结果(宿主 hard-fail 拒绝请求) + } + if converted == nil { + t.Fatalf("转换器 %s 返回 (nil, nil):宿主会 marshal nil 并向上游发出 \"null\"", converterID) + } + if _, err := json.Marshal(converted); err != nil { + t.Fatalf("转换器 %s 产出不可序列化的结果(宿主 marshal 将 hard-fail 该请求): %v", converterID, err) + } + }) +} + +// fuzzHostSeeds 种子语料:侧重宿主裸 unmarshal 才会产生的 []any content 形态, +// 以及各格式的多态/边界构造。 +var fuzzHostSeeds = []string{ + // content 双形态:字符串 / 数组(宿主解析后为 []any(map))/ 混杂元素 + `{"model":"m","messages":[{"role":"user","content":"hello"}]}`, + `{"model":"m","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, + `{"model":"m","messages":[{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image_url","image_url":{"url":"data:image/png;base64,AAA"}}]}]}`, + `{"model":"m","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":""}}]}]}`, + `{"model":"m","messages":[{"role":"user","content":[{"no_type":"x"}]}]}`, + `{"model":"m","messages":[{"role":"user","content":[null,0,"",[],{}]}]}`, + `{"model":"m","messages":[{"role":"user","content":[]}]}`, + `{"model":"m","messages":[{"role":"user","content":null}]}`, + // 工具循环 + `{"model":"m","messages":[{"role":"assistant","tool_calls":[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]},{"role":"tool","tool_call_id":"c1","content":"ok"}]}`, + `{"model":"m","messages":[],"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],"tool_choice":{"type":"function","function":{"name":"f"}}}`, + // Claude 原生 + `{"model":"m","max_tokens":1,"system":"s","messages":[{"role":"user","content":"hi"}]}`, + `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}],"thinking":{"type":"enabled","budget_tokens":1024}}`, + `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":"r"}]}]}`, + `{"model":"m","max_tokens":1,"messages":[],"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}]}`, + // Gemini 原生 + `{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":1}}`, + `{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"AAA"}}]}]}`, + `{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}}}]}],"tools":[{"googleSearch":{}}]}`, + `{"contents":[{"parts":[{"thought":true,"text":"t","thoughtSignature":"s"}]}]}`, + // Responses 原生 + `{"model":"m","input":"plain"}`, + `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}]}`, + `{"model":"m","input":[{"type":"function_call","call_id":"c","name":"f","arguments":"{}"},{"type":"function_call_output","call_id":"c","output":"r"}]}`, + `{"model":"m","input":[],"tools":[{"type":"web_search"}],"reasoning":{"effort":"medium"}}`, + // 最小 / 畸形边界 + `{}`, + `{"messages":null}`, + `null`, + `[]`, + ``, +} diff --git a/relay/handler/gemini_handler.go b/relay/handler/gemini_handler.go index 898296a..59a55d0 100644 --- a/relay/handler/gemini_handler.go +++ b/relay/handler/gemini_handler.go @@ -85,11 +85,12 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) { errStatus = "RESOURCE_EXHAUSTED" } else if errors.As(err, &relayErr) { statusCode = relayErr.StatusCode - errMsg = relayErr.Message + // 上游错误的 Message 是上游响应体原文,解包出可读消息再暴露给客户端 + errMsg = helper.UnwrapUpstreamErrorMessage(relayErr.Message) if relayErr.Cause != nil { // 传输层错误(client.Do 的 *url.Error)的 Cause 含上游域名,必须脱敏后再暴露给用户; // 日志侧仍用 originalError=%v 打印完整错误供运维定位。 - errMsg = relayErr.Message + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) + errMsg = errMsg + ": " + helper.SafeUpstreamErrorMessage(relayErr.Cause) } switch statusCode { case 401: @@ -100,6 +101,14 @@ func WriteGeminiRelayError(w http.ResponseWriter, err error) { errStatus = "INVALID_ARGUMENT" case 404: errStatus = "NOT_FOUND" + case 429: + // 限流:与 rateLimitErr 分支同口径。缺这条会让 429 带上 INTERNAL, + // 客户端(按 status 字符串判重试的 SDK)把可退避的限流当成服务端故障 + errStatus = "RESOURCE_EXHAUSTED" + case 503: + errStatus = "UNAVAILABLE" + case 504: + errStatus = "DEADLINE_EXCEEDED" default: errStatus = "INTERNAL" } diff --git a/relay/handler/matrix_guard_test.go b/relay/handler/matrix_guard_test.go new file mode 100644 index 0000000..65f077b --- /dev/null +++ b/relay/handler/matrix_guard_test.go @@ -0,0 +1,253 @@ +package handler + +// matrix_guard_test.go — relaykit 矩阵接管的供应商维度守卫。 +// +// 背景:relaykit 转换矩阵只按协议格式裁决(入站格式 × 有效上游格式),与供应商无关; +// 方向命中后 adaptor.ConvertRequest 整个不再被调用。但供应商差异是(供应商 × RelayMode) +// 维度的,两个维度不对齐时矩阵会在自己看不见的地方替供应商做错决定,且不报错—— +// 历史上已因此产生 4 个静默断链回归: +// 1. Anthropic 兼容端点漏登记(HasNativeClaudeEndpoint)→ Claude 体转成 chat 发到 +// Anthropic 端点、响应又按 OpenAI 解析,两侧同时断链; +// 2. 供应商私有后处理(RequestPostProcessor)丢失或双写; +// 3. 目标 adaptor 的 GetRequestURL 不认入站 RelayMode → DoRequest 报 +// "unsupported relay mode"、0ms 断链(bridge 测试只直测 DoResponse,测不到); +// 4. 多协议原生透传渠道(multinative)配模型映射时被矩阵按主协议 OpenAI 接管, +// chat 体发到 /v1/messages 原生端点,体与端点错配。 +// +// 本文件把「新增/修改转换方向必查三条」的人工评审清单固化为自动化测试: +// 枚举 registry 全部供应商 × 四种文本入站,任何新增供应商 / 新增方向 / 改动判定函数 +// 若破坏一致性会立即失败,不再依赖人记住检查项。 + +import ( + "testing" + + "github.com/qianfree/team-api/relay/channel" + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" +) + +// matrixInboundCombos 文本代理的四种入站(格式 × RelayMode)组合,与各入站 handler +// 的真实取值一致(mode 随请求全程不变,直达 adaptor.GetRequestURL)。 +var matrixInboundCombos = []struct { + inbound constant.RelayFormat + mode constant.RelayMode +}{ + {constant.RelayFormatOpenAI, constant.RelayModeChatCompletions}, + {constant.RelayFormatClaude, constant.RelayModeClaudeMessages}, + {constant.RelayFormatGemini, constant.RelayModeGeminiChat}, + {constant.RelayFormatResponses, constant.RelayModeResponses}, +} + +// guardProviders 枚举 registry 中全部有文本 adaptor 的供应商类型。 +// 上界取宽(现有编号最大 42),新增供应商自动纳入。 +func guardProviders() []constant.ProviderType { + var out []constant.ProviderType + for p := 1; p <= 64; p++ { + if channel.GetAdaptor(p) != nil { + out = append(out, constant.ProviderType(p)) + } + } + return out +} + +// guardChannelMeta 构造最小可用渠道元数据(个别 adaptor 对 ApiKey 形态有硬要求)。 +func guardChannelMeta(p constant.ProviderType) *common.ChannelMeta { + meta := &common.ChannelMeta{ + ChannelType: int(p), + BaseURL: "https://upstream.example.com", + ApiKey: "sk-guard-test", + UpstreamModelName: "guard-model", + } + if p == constant.ProviderCloudflare { + meta.ApiKey = "cf-token|cf-account" // cloudflare 的 URL 需要 accountID(token|accountid 格式) + } + return meta +} + +func guardInfo(p constant.ProviderType, inbound constant.RelayFormat, mode constant.RelayMode) *common.RelayInfo { + return &common.RelayInfo{ + InboundFormat: inbound, + RelayMode: int(mode), + ChannelMeta: guardChannelMeta(p), + } +} + +// matrixTakeoverExclusions 有意不参加「接管方向 URL 可达」守卫的供应商,必须注明原因。 +// 新增排除项前先确认它不是第 3 类断链——排除即承诺该组合在调度层不可达。 +var matrixTakeoverExclusions = map[constant.ProviderType]string{ + // codex 是 responses-only 上游(GetRequestURL 有意只认 Responses mode)。 + // 非 Responses 入站按主协议 OpenAI 参与矩阵裁决,但 codex 端点吃不了 chat 体。 + // 已知缺口:依赖渠道只挂 responses 系模型 + 调度 proto 门控避免命中; + // 若未来放开 codex 承接 chat/claude/gemini 入站,须先实现相应桥接再移除本排除。 + constant.ProviderCodex: "responses-only 上游,非 Responses 入站不可达(渠道模型配置 + 调度门控约束)", + // jimeng 是图片生成专用上游(GetRequestURL 有意只认 ImagesGenerations mode)。 + // 文本类入站不可达:jimeng 渠道只会挂图片模型,chat/claude/gemini/responses + // 请求不会被调度到该渠道。 + constant.ProviderJimeng: "图片生成专用上游,文本类入站不可达(渠道只挂图片模型)", +} + +// TestMatrixTakeover_AdaptorAcceptsInboundMode 守卫第 3 类断链: +// 矩阵接管的每个(供应商 × 入站)组合,目标 adaptor 的 GetRequestURL 必须认该入站 +// RelayMode——矩阵已把请求体转成上游格式,URL 拿不到就是 DoRequest 处 0ms 断链。 +func TestMatrixTakeover_AdaptorAcceptsInboundMode(t *testing.T) { + for _, p := range guardProviders() { + if reason, excluded := matrixTakeoverExclusions[p]; excluded { + t.Logf("跳过 %s(%d):%s", p.String(), p, reason) + continue + } + for _, combo := range matrixInboundCombos { + info := guardInfo(p, combo.inbound, combo.mode) + upstream := relaykit_bridge.EffectiveUpstreamFormat(info) + converterID := relaykit_bridge.RequestConverterIDForRoute(combo.inbound, upstream, info.RelayMode) + if converterID == "" { + continue // 同格式直连或矩阵未覆盖方向,不经 relaykit 接管 + } + + adaptor := channel.GetAdaptor(int(p)) + adaptor.Init(info) + url, err := adaptor.GetRequestURL(info) + if err != nil { + t.Errorf("%s(%d) 的 %s 入站(mode=%d)被矩阵接管(%s),但 GetRequestURL 报错:%v\n"+ + "→ 该组合会在 DoRequest 处 0ms 断链。修复:让该 adaptor 的 GetRequestURL 认此 mode"+ + "(矩阵已把请求体转成 %s 格式,通常应返回对应格式的主端点)", + p.String(), p, combo.inbound, combo.mode, converterID, err, upstream) + continue + } + if url == "" { + t.Errorf("%s(%d) 的 %s 入站:GetRequestURL 返回空 URL", p.String(), p, combo.inbound) + } + } + } +} + +// TestMatrixGuard_PassthroughNeverConverted 守卫第 1/4 类断链的共同根因: +// 两个判定函数必须口径一致——被 inboundMatchesChannelNative 判为「原生匹配」的 +// (供应商 × 入站),矩阵必须不产生转换器(有效上游格式 = 入站格式)。 +// +// 口径不一致时,同一请求在直连与转换两条路上得到不同格式结论:无映射/改写时直连正常, +// 一旦配置模型映射(canPassThrough=false 强制走转换路径)矩阵就按错误方向转换体, +// 而 adaptor 仍按原生格式选端点——即 multinative 断链(第 4 例)与 Anthropic +// 端点断链(第 1 例)的完整机理。 +func TestMatrixGuard_PassthroughNeverConverted(t *testing.T) { + for _, p := range guardProviders() { + for _, combo := range matrixInboundCombos { + info := guardInfo(p, combo.inbound, combo.mode) + if !inboundMatchesChannelNative(info) { + continue + } + upstream := relaykit_bridge.EffectiveUpstreamFormat(info) + if id := relaykit_bridge.RequestConverterIDForRoute(combo.inbound, upstream, info.RelayMode); id != "" { + t.Errorf("%s(%d) 的 %s 入站被判为原生匹配(可直连),但矩阵仍会转换(%s,有效上游=%s)\n"+ + "→ 配置模型映射后请求体会被转成 %s 而 adaptor 按 %s 原生端点发送,体与端点错配。"+ + "检查 EffectiveUpstreamFormat 与 inboundMatchesChannelNative 是否同步", + p.String(), p, combo.inbound, id, upstream, upstream, combo.inbound) + } + } + } + + // Responses 上游声明(supports_responses)同样两函数联动:原生匹配 ⇒ 不转换 + for _, p := range []constant.ProviderType{constant.ProviderOpenAI, constant.ProviderDeepSeek} { + info := guardInfo(p, constant.RelayFormatResponses, constant.RelayModeResponses) + info.ChannelMeta.SupportsResponses = true + if !inboundMatchesChannelNative(info) { + t.Errorf("%s(%d):supports_responses 渠道的 Responses 入站应判为原生匹配", p.String(), p) + } + upstream := relaykit_bridge.EffectiveUpstreamFormat(info) + if id := relaykit_bridge.RequestConverterIDForRoute(constant.RelayFormatResponses, upstream, info.RelayMode); id != "" { + t.Errorf("%s(%d):supports_responses 渠道的 Responses 入站不应被矩阵转换(got %s)", p.String(), p, id) + } + } +} + +// TestMatrixGuard_NativeClaudeEndpointRegistry 守卫第 1 类断链的登记完整性: +// HasNativeClaudeEndpoint 集合必须与各 adaptor 的真实端点行为一致。 +// 判据(与集合注释一致):GetRequestURL 对 ClaudeMessages 返回与 chat **不同**的地址 +// ⇔ 该供应商另挂 Anthropic 兼容端点 ⇔ 必须登记。 +// +// - 挂了独立端点却未登记 → Claude 入站被转成 chat 发到 Anthropic 端点(断链); +// - 未挂端点却登记了 → Claude 入站被直连到 chat 端点(chat 端点吃不了 Claude 体)。 +func TestMatrixGuard_NativeClaudeEndpointRegistry(t *testing.T) { + for _, p := range guardProviders() { + // 只考察主协议 OpenAI 的供应商(集合语义即「主协议 OpenAI + 另挂 Anthropic 端点」) + if helper.ProviderNativeFormat(int(p)) != constant.RelayFormatOpenAI { + continue + } + // 多协议原生透传渠道由 IsMultiNativeProvider 独立判定(三种入站全部原生直连), + // 不属于「另挂 Anthropic 端点」语义 + if constant.IsMultiNativeProvider(int(p)) { + continue + } + + // Claude 入站的 URL(按真实链路:Claude 入站 + ClaudeMessages mode 初始化 adaptor) + claudeInfo := guardInfo(p, constant.RelayFormatClaude, constant.RelayModeClaudeMessages) + claudeAdaptor := channel.GetAdaptor(int(p)) + claudeAdaptor.Init(claudeInfo) + claudeURL, claudeErr := claudeAdaptor.GetRequestURL(claudeInfo) + + // chat 入站的 URL + chatInfo := guardInfo(p, constant.RelayFormatOpenAI, constant.RelayModeChatCompletions) + chatAdaptor := channel.GetAdaptor(int(p)) + chatAdaptor.Init(chatInfo) + chatURL, chatErr := chatAdaptor.GetRequestURL(chatInfo) + + hasDistinctEndpoint := claudeErr == nil && chatErr == nil && claudeURL != chatURL + registered := constant.HasNativeClaudeEndpoint(int(p)) + + if hasDistinctEndpoint && !registered { + t.Errorf("%s(%d):ClaudeMessages 端点(%s)≠ chat 端点(%s),但未登记进 HasNativeClaudeEndpoint\n"+ + "→ Claude 入站会被矩阵转成 chat 发到 Anthropic 端点。请在 constant.HasNativeClaudeEndpoint 登记", + p.String(), p, claudeURL, chatURL) + } + if !hasDistinctEndpoint && registered { + t.Errorf("%s(%d):已登记 HasNativeClaudeEndpoint 但 ClaudeMessages 与 chat 端点相同(claude=%q err=%v, chat=%q err=%v)\n"+ + "→ Claude 入站会被直连到 chat 端点(chat 端点吃不了 Claude 体)。请移除登记或修 GetRequestURL", + p.String(), p, claudeURL, claudeErr, chatURL, chatErr) + } + } +} + +// expectedPostProcessors 实现了 common.RequestPostProcessor 的供应商期望清单。 +// 该接口仅在 relaykit 接管转换时由 convertRequestBody 调用,承载格式转换之外的 +// 供应商私有适配(zhipu GLM top_p 裁剪 + 图片前缀剥离、ali DashScope thinking_budget 裁剪)。 +var expectedPostProcessors = map[constant.ProviderType]bool{ + constant.ProviderAli: true, + constant.ProviderZhipu: true, + // vertex:Claude 模型需注入 anthropic_version 并删除 model 字段 + //(rawPredict 端点要求)。ConvertRequest 与本接口共用 adaptClaudeBodyForVertex, + // 该函数幂等(delete 幂等、anthropic_version 仅在缺失时写入) + constant.ProviderVertex: true, +} + +// TestMatrixGuard_RequestPostProcessorRegistry 守卫第 2 类断链:私有后处理的登记漂移。 +// +// - 新 adaptor 实现了接口却没进清单 → 失败提醒登记,并核对两条契约: +// ① adaptor.ConvertRequest 内部含同一段后处理(legacy 路径不经本接口); +// ② 后处理幂等(relaykit 通用处理可能已写同名字段,不得依赖字段不存在)。 +// - 清单里的供应商不再实现接口 → 失败提醒同步移除(多为重构时误删,实际是丢私有适配)。 +// +// 注意:dispatch 型 adaptor(volcengine/tencent)通过匿名嵌入接口字段委托,接口断言 +// 作用在外层 wrapper 上——若未来给其子 adaptor 添加 PostProcessor,必须让 wrapper +// 也实现并转发,否则 convertRequestBody 的断言拿不到(本测试同样会暴露这一点)。 +func TestMatrixGuard_RequestPostProcessorRegistry(t *testing.T) { + for _, p := range guardProviders() { + info := guardInfo(p, constant.RelayFormatOpenAI, constant.RelayModeChatCompletions) + adaptor := channel.GetAdaptor(int(p)) + adaptor.Init(info) + + _, implements := adaptor.(common.RequestPostProcessor) + expected := expectedPostProcessors[p] + + if implements && !expected { + t.Errorf("%s(%d) 实现了 RequestPostProcessor 但未登记进 expectedPostProcessors\n"+ + "→ 请登记,并核对:① ConvertRequest 内部含同一段后处理(legacy 路径);② 后处理幂等", + p.String(), p) + } + if !implements && expected { + t.Errorf("%s(%d) 在 expectedPostProcessors 清单中但 adaptor 未实现 RequestPostProcessor\n"+ + "→ 私有后处理疑似在重构中丢失(relaykit 接管路径将静默跳过参数裁剪),请核实", + p.String(), p) + } + } +} diff --git a/relay/handler/passthrough.go b/relay/handler/passthrough.go index d665d46..c06fa8b 100644 --- a/relay/handler/passthrough.go +++ b/relay/handler/passthrough.go @@ -19,12 +19,21 @@ func canPassThrough(info *common.RelayInfo) bool { } // responses-only 桥接渠道的 chat 入站:chat 体与上游 /v1/responses 端点不兼容, - // 必须经 ConvertOpenAIToResponses 桥接转换,即使显式开启直连也不得原样透传。 + // 必须经 relaykit chat→Responses 桥接转换,即使显式开启直连也不得原样透传。 if info.ChannelMeta.ChatViaResponses && constant.RelayMode(info.RelayMode) == constant.RelayModeChatCompletions { return false } + // Vertex AI 的 Claude 模型:rawPredict 端点要求体内带 anthropic_version、 + // 且不接受 model 字段(模型由 URL 指定)。这段改写在 adaptor.ConvertRequest 与 + // PostProcessConvertedRequest 中完成,原样直连会绕过两者、上游直接 400, + // 因此即使显式开启直连也不得透传。 + if constant.ProviderType(info.ChannelMeta.ChannelType) == constant.ProviderVertex && + helper.ProviderNativeFormatFor(info) == constant.RelayFormatClaude { + return false + } + // 显式开启:运营者明确配置直连,不做额外检查 if settings.PassThroughBodyEnabled { return true @@ -47,10 +56,6 @@ func canPassThrough(info *common.RelayInfo) bool { if settings.SystemPrompt != "" { return false } - // 有 thinking 后缀 → 必须经过转换来注入 thinking 参数 - if info.ThinkingEnabled || info.ThinkingDisabled || info.ReasoningEffort != "" { - return false - } return true } @@ -60,7 +65,7 @@ func canPassThrough(info *common.RelayInfo) bool { // - 多协议原生透传渠道(New API / Sub2API):OpenAI/Claude/Gemini 三种格式均视为匹配。 // // 注意:chat_via_responses(responses-only 桥接渠道)的 chat 入站由 canPassThrough -// 前置硬排除——chat 体必须经 ConvertOpenAIToResponses 转换后才能发 /v1/responses。 +// 前置硬排除——chat 体必须经 relaykit chat→Responses 转换后才能发 /v1/responses。 func inboundMatchesChannelNative(info *common.RelayInfo) bool { if info.ChannelMeta.UpstreamSpeaksResponses() { return info.InboundFormat == constant.RelayFormatResponses @@ -72,5 +77,11 @@ func inboundMatchesChannelNative(info *common.RelayInfo) bool { } return false } - return helper.ProviderNativeFormat(info.ChannelMeta.ChannelType) == info.InboundFormat + // Anthropic 兼容端点渠道:主协议是 OpenAI,但 Claude 入站走独立的 Anthropic + // 端点且两侧均为 Claude 口径,故 Claude 入站同样算「匹配原生」可直连。 + if info.InboundFormat == constant.RelayFormatClaude && + constant.HasNativeClaudeEndpoint(info.ChannelMeta.ChannelType) { + return true + } + return helper.ProviderNativeFormatFor(info) == info.InboundFormat } diff --git a/relay/handler/passthrough_test.go b/relay/handler/passthrough_test.go index 51b1da6..25ab1cd 100644 --- a/relay/handler/passthrough_test.go +++ b/relay/handler/passthrough_test.go @@ -5,6 +5,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/relaykit_bridge" "github.com/qianfree/team-api/relaykit/relayconvert" ) @@ -277,9 +278,9 @@ func TestCanPassThrough_ChatViaResponses_ResponsesInbound(t *testing.T) { } } -// TestRelaykitRequestConverterID 验证请求侧 converter ID 解析,含新供应商 -// 与 Ollama 仅 chat 路径启用的 RelayMode 守卫。 -func TestRelaykitRequestConverterID(t *testing.T) { +// TestRequestConverterIDForRoute 验证请求侧 converter ID 解析(矩阵已统一收敛到 +// relaykit_bridge.RequestConverterIDForRoute),含反向、跨原生方向与 Ollama 的 RelayMode 守卫。 +func TestRequestConverterIDForRoute(t *testing.T) { tests := []struct { name string inbound constant.RelayFormat @@ -289,19 +290,167 @@ func TestRelaykitRequestConverterID(t *testing.T) { }{ {"OpenAI→Claude", constant.RelayFormatOpenAI, constant.RelayFormatClaude, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToClaudeMessages}, {"OpenAI→Gemini", constant.RelayFormatOpenAI, constant.RelayFormatGemini, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToGeminiContent}, - {"OpenAI→Coze", constant.RelayFormatOpenAI, constant.RelayFormatCoze, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToCoze}, - {"OpenAI→Dify", constant.RelayFormatOpenAI, constant.RelayFormatDify, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToDify}, {"OpenAI→Ollama chat", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToOllama}, {"OpenAI→Ollama generate 不迁移", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeCompletions), ""}, {"OpenAI→Ollama embedding 不迁移", constant.RelayFormatOpenAI, constant.RelayFormatOllama, int(constant.RelayModeEmbeddings), ""}, {"同格式不转换", constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, int(constant.RelayModeChatCompletions), ""}, + {"Claude→OpenAI 反向", constant.RelayFormatClaude, constant.RelayFormatOpenAI, int(constant.RelayModeClaudeMessages), relayconvert.ConverterClaudeMessagesToOpenAIChat}, + {"Gemini→OpenAI 反向", constant.RelayFormatGemini, constant.RelayFormatOpenAI, int(constant.RelayModeGeminiChat), relayconvert.ConverterGeminiContentToOpenAIChat}, + {"Responses→OpenAI", constant.RelayFormatResponses, constant.RelayFormatOpenAI, int(constant.RelayModeResponses), relayconvert.ConverterOpenAIResponsesToOpenAIChat}, + {"OpenAI→Responses", constant.RelayFormatOpenAI, constant.RelayFormatResponses, int(constant.RelayModeChatCompletions), relayconvert.ConverterOpenAIChatToOpenAIResponses}, + {"Claude→Gemini 跨原生", constant.RelayFormatClaude, constant.RelayFormatGemini, int(constant.RelayModeClaudeMessages), relayconvert.ConverterClaudeMessagesToGeminiContent}, + {"Gemini→Claude 跨原生", constant.RelayFormatGemini, constant.RelayFormatClaude, int(constant.RelayModeGeminiChat), relayconvert.ConverterGeminiContentToClaudeMessages}, + {"Responses→Claude 跨原生", constant.RelayFormatResponses, constant.RelayFormatClaude, int(constant.RelayModeResponses), relayconvert.ConverterResponsesToClaudeMessages}, + {"Responses→Gemini 跨原生", constant.RelayFormatResponses, constant.RelayFormatGemini, int(constant.RelayModeResponses), relayconvert.ConverterOpenAIResponsesToGemini}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := relaykitRequestConverterID(tt.inbound, tt.upstream, tt.relayMode) + got := relaykit_bridge.RequestConverterIDForRoute(tt.inbound, tt.upstream, tt.relayMode) if got != tt.want { - t.Errorf("relaykitRequestConverterID(%s,%s,%d) = %q, want %q", tt.inbound, tt.upstream, tt.relayMode, got, tt.want) + t.Errorf("RequestConverterIDForRoute(%s,%s,%d) = %q, want %q", tt.inbound, tt.upstream, tt.relayMode, got, tt.want) } }) } } + +// nativeClaudeInfo 构造 Claude 入站 + 指定供应商的 RelayInfo(无映射/改写,纯格式判定) +func nativeClaudeInfo(provider constant.ProviderType, inbound constant.RelayFormat) *common.RelayInfo { + return &common.RelayInfo{ + InboundFormat: inbound, + RelayMode: int(constant.RelayModeClaudeMessages), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(provider), + Settings: common.ChannelSettings{}, + }, + } +} + +// TestEffectiveUpstreamFormat_NativeClaudeEndpoint 另挂 Anthropic 兼容端点的供应商, +// Claude 入站的有效上游格式必须是 Claude(同格式 → 不转换)。 +// +// 判成 OpenAI 会两侧同时断链:请求体转成 chat 发到 /anthropic/v1/messages(上游 400), +// 响应侧又把 Claude 响应体当 OpenAI 解析。 +func TestEffectiveUpstreamFormat_NativeClaudeEndpoint(t *testing.T) { + // 另挂 Anthropic 端点(ClaudeMessages 走独立地址) + nativeClaude := []constant.ProviderType{ + constant.ProviderAli, constant.ProviderZhipu, constant.ProviderDeepSeek, + constant.ProviderMoonshot, constant.ProviderVolcengine, constant.ProviderMiniMax, + } + for _, p := range nativeClaude { + info := nativeClaudeInfo(p, constant.RelayFormatClaude) + if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatClaude { + t.Errorf("provider %d: EffectiveUpstreamFormat = %q, want %q", p, got, constant.RelayFormatClaude) + } + // 同格式 → 矩阵不得返回转换器 + if id := relaykit_bridge.RequestConverterIDForRoute(constant.RelayFormatClaude, + relaykit_bridge.EffectiveUpstreamFormat(info), info.RelayMode); id != "" { + t.Errorf("provider %d: 不应命中转换器, got %q", p, id) + } + } + + // ClaudeMessages 与 chat 共用同一端点 → 上游只说 OpenAI,Claude 入站仍须转换 + openaiOnly := []constant.ProviderType{constant.ProviderXAI, constant.ProviderBaiduV2} + for _, p := range openaiOnly { + info := nativeClaudeInfo(p, constant.RelayFormatClaude) + if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatOpenAI { + t.Errorf("provider %d: EffectiveUpstreamFormat = %q, want %q", p, got, constant.RelayFormatOpenAI) + } + } + + // 覆盖只对 Claude 入站生效:同一供应商的 OpenAI 入站仍是 OpenAI + info := nativeClaudeInfo(constant.ProviderDeepSeek, constant.RelayFormatOpenAI) + info.RelayMode = int(constant.RelayModeChatCompletions) + if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatOpenAI { + t.Errorf("OpenAI 入站不应被覆盖: got %q, want %q", got, constant.RelayFormatOpenAI) + } +} + +// TestCanPassThrough_NativeClaudeEndpoint_ClaudeInbound 同一批供应商的 Claude 入站 +// 在无映射/改写时应可原样直连(上游端点本就吃 Claude 体)。 +func TestCanPassThrough_NativeClaudeEndpoint_ClaudeInbound(t *testing.T) { + info := nativeClaudeInfo(constant.ProviderDeepSeek, constant.RelayFormatClaude) + if !canPassThrough(info) { + t.Error("Anthropic 兼容端点渠道的 Claude 入站应可直连") + } + + // 有模型映射时仍须经转换(改写模型名),但方向是 Claude→Claude 而非 Claude→OpenAI + info.ChannelMeta.IsModelMapped = true + if canPassThrough(info) { + t.Error("有模型映射时不应直连") + } + if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != constant.RelayFormatClaude { + t.Errorf("EffectiveUpstreamFormat = %q, want %q", got, constant.RelayFormatClaude) + } +} + +// vertexInfo 构造 Vertex 渠道的 RelayInfo(模型名驱动协议分流)。 +func vertexInfo(model string, inbound constant.RelayFormat, mode constant.RelayMode) *common.RelayInfo { + return &common.RelayInfo{ + InboundFormat: inbound, + ClientFormat: inbound, + RelayMode: int(mode), + OriginModelName: model, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderVertex), + UpstreamModelName: model, + Settings: common.ChannelSettings{}, + }, + } +} + +// TestEffectiveUpstreamFormat_VertexIsModelDriven Vertex 是模型名驱动的双协议上游: +// 协议判定必须跟着模型走,否则矩阵按渠道类型判成 openai,把请求体转成 chat +// 发到 :generateContent / :rawPredict 原生端点,上游必然 400。 +func TestEffectiveUpstreamFormat_VertexIsModelDriven(t *testing.T) { + cases := []struct { + model string + want constant.RelayFormat + }{ + {"gemini-2.5-pro", constant.RelayFormatGemini}, + {"claude-sonnet-4-5", constant.RelayFormatClaude}, + } + for _, tc := range cases { + // 四种入站格式下有效上游格式都应等于模型对应的原生协议 + for _, inbound := range []constant.RelayFormat{ + constant.RelayFormatOpenAI, constant.RelayFormatClaude, + constant.RelayFormatGemini, constant.RelayFormatResponses, + } { + info := vertexInfo(tc.model, inbound, constant.RelayModeChatCompletions) + if got := relaykit_bridge.EffectiveUpstreamFormat(info); got != tc.want { + t.Errorf("模型 %s / %s 入站: EffectiveUpstreamFormat = %q, want %q", + tc.model, inbound, got, tc.want) + } + } + } +} + +// TestCanPassThrough_VertexNativeInboundMatches 与模型协议相同的入站可直连 +// (Gemini 模型 + Gemini 入站:体与 :generateContent 端点天然匹配)。 +func TestCanPassThrough_VertexNativeInboundMatches(t *testing.T) { + info := vertexInfo("gemini-2.5-pro", constant.RelayFormatGemini, constant.RelayModeGeminiChat) + if !canPassThrough(info) { + t.Error("Vertex Gemini 模型的 Gemini 入站应可直连") + } + + // OpenAI 入站与 Gemini 端点不匹配,必须经转换 + info = vertexInfo("gemini-2.5-pro", constant.RelayFormatOpenAI, constant.RelayModeChatCompletions) + if canPassThrough(info) { + t.Error("Vertex Gemini 模型的 OpenAI 入站不得直连(chat 体发不了 :generateContent)") + } +} + +// TestCanPassThrough_VertexClaudeNeverPassesThrough Vertex 的 Claude 模型即使入站 +// 就是 Claude 格式也不得直连:rawPredict 端点要求体内带 anthropic_version、不接受 +// model 字段,这段改写只在 ConvertRequest / PostProcessConvertedRequest 中执行, +// 直连会绕过两者导致上游 400。显式开启 pass_through_body_enabled 也不例外。 +func TestCanPassThrough_VertexClaudeNeverPassesThrough(t *testing.T) { + info := vertexInfo("claude-sonnet-4-5", constant.RelayFormatClaude, constant.RelayModeClaudeMessages) + if canPassThrough(info) { + t.Error("Vertex Claude 模型的 Claude 入站不得直连(会绕过 anthropic_version 注入)") + } + + info.ChannelMeta.Settings.PassThroughBodyEnabled = true + if canPassThrough(info) { + t.Error("即使显式开启直连,Vertex Claude 模型仍不得原样透传") + } +} diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index aa9d3c4..e933ffd 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -59,7 +59,6 @@ type relayValidation struct { relayModeStr string modelName string lookupModel string - thinkingInfo *helper.ThinkingInfo isStream bool estimatedTokens int maxTokens int @@ -72,26 +71,15 @@ type modelMappingProvider interface { GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error) } -// resolveRelayModel gives a literal catalog model precedence over the optional -// thinking/effort suffix syntax. This avoids treating names such as -// "qwen3.8-max" as the virtual model "qwen3.8" with effort=max. -func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, *helper.ThinkingInfo, error) { - parsed := helper.ParseThinkingSuffix(modelName) - literal := &helper.ThinkingInfo{BaseModel: modelName} - - _, _, err := provider.GetModelMapping(ctx, modelName) - if err == nil { - return modelName, literal, nil +// resolveRelayModel 校验模型在目录中存在,返回计费/调度用的目录模型名。 +// 历史上此处还承担 thinking/effort 模型名后缀(-thinking/-high 等虚拟模型语法)的 +// 解析回退;该语法已于 2026-09 移除(无存量使用,思考参数一律由客户端请求参数表达), +// 目录名即请求名。 +func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, error) { + if _, _, err := provider.GetModelMapping(ctx, modelName); err != nil { + return "", err } - if parsed.BaseModel == modelName { - return "", nil, err - } - - _, _, err = provider.GetModelMapping(ctx, parsed.BaseModel) - if err != nil { - return "", nil, err - } - return parsed.BaseModel, &parsed, nil + return modelName, nil } // validateRelayRequest 校验请求合法性:relay mode、QPS 限流(前置)、模型存在性、弃用状态、成员/API Key 模型范围。 @@ -161,9 +149,8 @@ func validateRelayRequest( return nil, constant.NewRequestError("model is required", nil) } - // 2.5 解析 thinking/effort 后缀并验证模型。完整模型名存在时按字面模型处理; - // 仅在完整名称不存在时,才尝试将后缀解析为虚拟 thinking/effort 参数。 - lookupModel, thinkingInfo, modelErr := resolveRelayModel(ctx, provider, modelName) + // 2.5 验证模型在目录中存在 + lookupModel, modelErr := resolveRelayModel(ctx, provider, modelName) if modelErr != nil { if modelErr == common.ErrModelNotFound { return nil, constant.NewRequestError("model not found: "+modelName, modelErr) @@ -230,7 +217,6 @@ func validateRelayRequest( relayModeStr: relayModeStr, modelName: modelName, lookupModel: lookupModel, - thinkingInfo: thinkingInfo, isStream: isStream, estimatedTokens: estimatedInputTokens, maxTokens: maxTokens, @@ -374,7 +360,6 @@ func settleSuccessfulRequest( UserAgent: headers.Get("User-Agent"), ClientIP: rc.ClientIP, FirstTokenMs: firstTokenMs, - ReasoningEffort: info.ReasoningEffort, InboundEndpoint: path, // 渠道详情 @@ -845,25 +830,22 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // attempt 为当前重试轮次(0=首次),写入 RetryIndex 供 ParamOverride「是否重试」规则与 bil_usage_logs.retry_index 使用(C3)。 func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, selection *common.ChannelSelection, path string, headers http.Header, attempt int) *common.RelayInfo { info := &common.RelayInfo{ - Context: ctx, - TenantID: rc.TenantID, - UserID: rc.UserID, - ApiKeyID: rc.ApiKeyID, - ProjectID: rc.ProjectID, - RequestID: rc.RequestID, - RetryIndex: attempt, - RelayMode: int(v.relayMode), - IsStream: v.isStream, - OriginModelName: v.modelName, - BaseModelName: v.lookupModel, - ThinkingEnabled: v.thinkingInfo.IsThinking, - ThinkingDisabled: v.thinkingInfo.IsNoThinking, - ReasoningEffort: v.thinkingInfo.EffortLevel, - RequestURLPath: path, - RequestHeaders: headers, - StartTime: time.Now(), - StreamStatus: common.NewStreamStatus(), - InboundFormat: relayModeToInboundFormat(v.relayMode), + Context: ctx, + TenantID: rc.TenantID, + UserID: rc.UserID, + ApiKeyID: rc.ApiKeyID, + ProjectID: rc.ProjectID, + RequestID: rc.RequestID, + RetryIndex: attempt, + RelayMode: int(v.relayMode), + IsStream: v.isStream, + OriginModelName: v.modelName, + BaseModelName: v.lookupModel, + RequestURLPath: path, + RequestHeaders: headers, + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + InboundFormat: relayModeToInboundFormat(v.relayMode), ChannelMeta: &common.ChannelMeta{ ChannelID: selection.ChannelID, ChannelType: selection.ChannelType, @@ -898,11 +880,36 @@ func convertRequestBody(ctx context.Context, info *common.RelayInfo, body []byte return bytes.NewReader(body), nil } - // relaykit 转换器路径(特性开关控制,默认关闭)。失败/未启用回退旧代码路径。 + // relaykit 转换器路径(唯一路径,hard-fail):方向命中后解析/转换失败直接拒绝请求; + // 未命中方向(同格式直连、非文本模式等)走 adaptor 的原生后处理路径。 var convertedBody io.Reader - if relaykitBody, ok := tryConvertRequestViaRelaykit(ctx, info, body); ok { + relaykitBody, handled, relaykitErr := tryConvertRequestViaRelaykit(ctx, info, body) + switch { + case handled && relaykitErr != nil: + if !errors.Is(relaykitErr, constant.ErrStatefulResponsesUnsupported) { + g.Log().Errorf(ctx, "[RelayHandler] relaykit ConvertRequest failed: inboundFormat=%s, error=%v", + info.InboundFormat, relaykitErr) + } + return nil, relaykitErr + case handled: convertedBody = relaykitBody - } else { + // 供应商私有后处理:矩阵按格式裁决、与供应商无关,命中后 adaptor.ConvertRequest + // 不再被调用,其中的私有请求适配(zhipu GLM 参数兼容、ali DashScope 参数裁剪) + // 会随之丢失。此处按可选接口接回,仅 relaykit 路径调用。 + if pp, ok := adaptor.(common.RequestPostProcessor); ok { + relaykitBytes, err := io.ReadAll(convertedBody) + if err != nil { + return nil, fmt.Errorf("read relaykit converted body: %w", err) + } + processed, err := pp.PostProcessConvertedRequest(ctx, info, relaykitBytes) + if err != nil { + g.Log().Errorf(ctx, "[RelayHandler] provider request post-process failed: adaptor=%s, inboundFormat=%s, error=%v", + adaptor.GetChannelName(), info.InboundFormat, err) + return nil, err + } + convertedBody = bytes.NewReader(processed) + } + default: legacyBody, err := adaptor.ConvertRequest(ctx, info, body) if err != nil { // responses 有状态协议不匹配为预期内错误(上层按哨兵驱动换渠道并向客户端返回 diff --git a/relay/handler/relay_handler_test.go b/relay/handler/relay_handler_test.go index 5d427c4..4b5b17c 100644 --- a/relay/handler/relay_handler_test.go +++ b/relay/handler/relay_handler_test.go @@ -21,76 +21,42 @@ func (s *modelMappingStub) GetModelMapping(_ context.Context, modelName string) } func TestResolveRelayModel(t *testing.T) { - tests := []struct { - name string - model string - models map[string]bool - wantLookup string - wantEffort string - wantCalls []string - wantErr bool - }{ - { - name: "literal model ending in effort suffix wins", - model: "qwen3.8-max", - models: map[string]bool{"qwen3.8-max": true, "qwen3.8": true}, - wantLookup: "qwen3.8-max", - wantCalls: []string{"qwen3.8-max"}, - }, - { - name: "virtual effort suffix falls back to base model", - model: "o3-max", - models: map[string]bool{"o3": true}, - wantLookup: "o3", - wantEffort: "max", - wantCalls: []string{"o3-max", "o3"}, - }, - { - name: "model without suffix resolves literally", - model: "gpt-4o", - models: map[string]bool{"gpt-4o": true}, - wantLookup: "gpt-4o", - wantCalls: []string{"gpt-4o"}, - }, - { - name: "missing literal and base model returns error", - model: "missing-high", - models: map[string]bool{}, - wantCalls: []string{"missing-high", "missing"}, - wantErr: true, - }, - } + // 后缀语法已移除:目录名即请求名,不再有「字面模型优先 / 剥后缀回退」两段查找 + t.Run("catalog model resolves literally", func(t *testing.T) { + provider := &modelMappingStub{models: map[string]bool{"gpt-4o": true}} + lookup, err := resolveRelayModel(t.Context(), provider, "gpt-4o") + if err != nil { + t.Fatalf("resolveRelayModel() error = %v", err) + } + if lookup != "gpt-4o" { + t.Errorf("lookup model = %q, want %q", lookup, "gpt-4o") + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := &modelMappingStub{models: tt.models} - lookup, thinking, err := resolveRelayModel(t.Context(), provider, tt.model) - if (err != nil) != tt.wantErr { - t.Fatalf("resolveRelayModel() error = %v, wantErr %v", err, tt.wantErr) - } - if lookup != tt.wantLookup { - t.Errorf("lookup model = %q, want %q", lookup, tt.wantLookup) - } - if !tt.wantErr { - if thinking == nil { - t.Fatal("thinking info is nil") - } - if thinking.BaseModel != tt.wantLookup { - t.Errorf("thinking base model = %q, want %q", thinking.BaseModel, tt.wantLookup) - } - if thinking.EffortLevel != tt.wantEffort { - t.Errorf("effort level = %q, want %q", thinking.EffortLevel, tt.wantEffort) - } - } - if len(provider.calls) != len(tt.wantCalls) { - t.Fatalf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) - } - for i := range tt.wantCalls { - if provider.calls[i] != tt.wantCalls[i] { - t.Errorf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) - break - } - } - }) - } + // 名字带 -max 等字样的真实目录模型按字面命中,不做任何后缀解释 + t.Run("literal model ending in effort-like word", func(t *testing.T) { + provider := &modelMappingStub{models: map[string]bool{"qwen3.8-max": true}} + lookup, err := resolveRelayModel(t.Context(), provider, "qwen3.8-max") + if err != nil { + t.Fatalf("resolveRelayModel() error = %v", err) + } + if lookup != "qwen3.8-max" { + t.Errorf("lookup model = %q, want %q", lookup, "qwen3.8-max") + } + if len(provider.calls) != 1 || provider.calls[0] != "qwen3.8-max" { + t.Errorf("lookup calls = %v, want exactly one literal lookup", provider.calls) + } + }) + + // 目录中不存在即报错,不再尝试剥后缀回退("o3-max" 不会命中 "o3") + t.Run("missing model returns error without suffix fallback", func(t *testing.T) { + provider := &modelMappingStub{models: map[string]bool{"o3": true}} + _, err := resolveRelayModel(t.Context(), provider, "o3-max") + if err == nil { + t.Fatal("want error for model absent from catalog") + } + if len(provider.calls) != 1 { + t.Errorf("lookup calls = %v, want single literal lookup", provider.calls) + } + }) } diff --git a/relay/handler/relaykit_bridge.go b/relay/handler/relaykit_bridge.go index 65fc6f1..9c043bb 100644 --- a/relay/handler/relaykit_bridge.go +++ b/relay/handler/relaykit_bridge.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "errors" + "fmt" "io" "time" @@ -12,97 +14,115 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relay/relaykit_bridge" "github.com/qianfree/team-api/relaykit/relayconvert" - - // blank import 触发内置转换器注册(register.init() 调用 RegisterTextConverter) - _ "github.com/qianfree/team-api/relaykit/relayconvert/register" ) -// relaykit 请求转换器接入桥接层。 +// relaykit 请求转换器接入桥接层(唯一路径,hard-fail)。 // // 设计要点: -// - 特性开关已移除(relaykit 常开):relaykit 在其覆盖的转换方向上始终优先。 -// - 只替换「格式转换」这一步;转换后仍复用旧路径的系统提示词注入 / 参数改写 / 字段清理。 -// - 任何失败(无匹配转换器、解析失败、转换失败、同格式、Ollama 非 chat 模式)都返回 ok=false, -// 调用方回退到 adaptor.ConvertRequest 旧代码路径,保证请求不因 relaykit 中断。 +// - 转换方向由 relaykit_bridge 的方向矩阵统一裁决(入站格式 × 有效上游格式); +// - 方向命中后 relaykit 即唯一路径:解析失败/转换失败直接向上返回错误,不再回退 +// 旧 adaptor 转换(畸形请求体 hard-fail); +// - 方向未命中(同格式直连、audio/rerank 等非文本模式、Ollama generate/embedding) +// 返回 handled=false,调用方走 adaptor.ConvertRequest 的原生后处理路径; +// - 模型名替换与 thinking 注入已内化在 relaykit 转换器中;OpenAI chat 上游独有的 +// stream_options / reasoning_effort 注入由本层在转换后补齐(与旧 openai adaptor 一致); // - 转换耗时与成败通过 monitor.TrackConverterCall 记录,供 dashboard 观测。 -// relaykitRequestConverterID 根据 (客户端入站格式, 上游原生格式, RelayMode) 返回已注册的请求转换器 ID。 -// 返回空串表示没有匹配的 relaykit 转换器(调用方回退旧路径)。 -// Ollama 仅注册了 chat 路径转换器,其 generate/embedding 模式返回空串以回退旧 adaptor。 -func relaykitRequestConverterID(inbound, upstream constant.RelayFormat, relayMode int) string { - if inbound == upstream { - return "" // 同格式无需转换(这类请求本就走 passthrough) - } - switch { - case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatClaude: - return relayconvert.ConverterOpenAIChatToClaudeMessages - case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatGemini: - return relayconvert.ConverterOpenAIChatToGeminiContent - case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatCoze: - return relayconvert.ConverterOpenAIChatToCoze - case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatDify: - return relayconvert.ConverterOpenAIChatToDify - case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatOllama: - // 仅 chat 路径迁移;generate(completions)/ embedding 回退旧 adaptor - if constant.RelayMode(relayMode) != constant.RelayModeChatCompletions { - return "" - } - return relayconvert.ConverterOpenAIChatToOllama - default: - return "" - } -} - // tryConvertRequestViaRelaykit 尝试用 relaykit 转换器转换请求体。 -// 成功返回 (转换后的 io.Reader, true);无匹配 / 解析或转换失败返回 (nil, false)。 // -// 结构与流式桥接(relay/relaykit_bridge/stream.go)对称:nil 守卫留在公开入口, -// 真正的转换逻辑抽到 config-free 的 convertRequestViaRelaykit 核心以便单测直接覆盖。 -func tryConvertRequestViaRelaykit(ctx context.Context, info *common.RelayInfo, body []byte) (io.Reader, bool) { +// 返回值语义: +// - handled=false, err=nil:该方向无 relaykit 转换器,调用方走 adaptor 路径; +// - handled=true, err!=nil:方向命中但解析/转换失败,调用方应直接返回错误; +// - handled=true, err=nil:转换成功。 +func tryConvertRequestViaRelaykit(ctx context.Context, info *common.RelayInfo, body []byte) (io.Reader, bool, error) { if info == nil || info.ChannelMeta == nil { - return nil, false + return nil, false, nil } return convertRequestViaRelaykit(ctx, info, body) } -// convertRequestViaRelaykit 是请求转换的 config-free 核心(特性开关已由调用方校验)。 -// info 与 info.ChannelMeta 必须非空。 -func convertRequestViaRelaykit(ctx context.Context, info *common.RelayInfo, body []byte) (io.Reader, bool) { +// convertRequestViaRelaykit 是请求转换的核心(info 与 info.ChannelMeta 必须非空)。 +func convertRequestViaRelaykit(ctx context.Context, info *common.RelayInfo, body []byte) (io.Reader, bool, error) { inbound := info.InboundFormat - upstream := helper.ProviderNativeFormat(info.ChannelMeta.ChannelType) - converterID := relaykitRequestConverterID(inbound, upstream, info.RelayMode) + upstream := relaykit_bridge.EffectiveUpstreamFormat(info) + converterID := relaykit_bridge.RequestConverterIDForRoute(inbound, upstream, info.RelayMode) if converterID == "" { - return nil, false + return nil, false, nil } - spec, ok := relayconvert.LookupTextConverter(converterID) - if !ok || spec.Req.Convert == nil { - return nil, false + if _, ok := relayconvert.LookupRequestConverter(converterID); !ok { + // 矩阵返回了 ID 但注册表没有:注册缺失属程序性错误,按未接管处理并告警 + g.Log().Errorf(ctx, "[relaykit] request converter %q not registered (inbound=%s upstream=%s)", converterID, inbound, upstream) + return nil, false, nil } - // 解析入站请求体为 OpenAI 请求 DTO(当前两个转换器入参均为 *dto.GeneralOpenAIRequest)。 - var openaiReq dto.GeneralOpenAIRequest - if err := json.Unmarshal(body, &openaiReq); err != nil { - g.Log().Warningf(ctx, "[relaykit] parse inbound request failed, fallback to legacy: %v", err) - return nil, false + // 按入站格式解析请求体(hard-fail:解析失败即拒绝请求) + parsed, err := parseInboundRequest(inbound, body) + if err != nil { + return nil, true, constant.NewRequestError(fmt.Sprintf("请求体解析失败(%s 格式)", inbound), err) } start := time.Now() - converted, err := spec.Req.Convert(ctx, info, &openaiReq) + converted, err := relayconvert.ConvertRequestByID(ctx, info, converterID, parsed) duration := time.Since(start) monitor.TrackConverterCall(converterID, string(inbound), string(upstream), duration, err) if err != nil { - g.Log().Warningf(ctx, "[relaykit] convert request failed (converter=%s), fallback to legacy: %v", converterID, err) - return nil, false + // responses 有状态协议不匹配:映射为宿主哨兵错误,上层按渠道级致命驱动换渠道 + if errors.Is(err, relayconvert.ErrStatefulResponsesUnsupported) { + return nil, true, fmt.Errorf("stateful responses not supported by chat-only channel: %w", constant.ErrStatefulResponsesUnsupported) + } + g.Log().Warningf(ctx, "[relaykit] convert request failed (converter=%s): %v", converterID, err) + return nil, true, fmt.Errorf("convert request (%s): %w", converterID, err) + } + + // OpenAI chat 上游后处理:流式请求补 stream_options(客户端未显式设置时,计费需要 usage) + if upstream == constant.RelayFormatOpenAI { + if req, ok := converted.(*dto.GeneralOpenAIRequest); ok { + if info.IsStream && req.StreamOptions == nil { + req.StreamOptions = &dto.StreamOptions{IncludeUsage: true} + } + } } out, err := json.Marshal(converted) if err != nil { - g.Log().Warningf(ctx, "[relaykit] marshal converted request failed (converter=%s), fallback to legacy: %v", converterID, err) - return nil, false + g.Log().Errorf(ctx, "[relaykit] marshal converted request failed (converter=%s): %v", converterID, err) + return nil, true, fmt.Errorf("marshal converted request (%s): %w", converterID, err) } - return bytes.NewReader(out), true + return bytes.NewReader(out), true, nil +} + +// parseInboundRequest 按入站格式把请求体解析为对应 DTO 指针。 +func parseInboundRequest(inbound constant.RelayFormat, body []byte) (any, error) { + switch inbound { + case constant.RelayFormatOpenAI: + var req dto.GeneralOpenAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + return &req, nil + case constant.RelayFormatClaude: + var req dto.ClaudeRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + return &req, nil + case constant.RelayFormatGemini: + var req dto.GeminiChatRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + return &req, nil + case constant.RelayFormatResponses: + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + return &req, nil + default: + return nil, fmt.Errorf("unsupported inbound format %q", inbound) + } } diff --git a/relay/handler/relaykit_bridge_test.go b/relay/handler/relaykit_bridge_test.go index 1818687..33880fe 100644 --- a/relay/handler/relaykit_bridge_test.go +++ b/relay/handler/relaykit_bridge_test.go @@ -64,23 +64,21 @@ const openAIRequestBody = `{"model":"gpt-4","max_tokens":256,"messages":[{"role" func TestConvertRequestViaRelaykit_AllProviders(t *testing.T) { cases := []struct { - name string - channel constant.ProviderType - upstream string - wantKeys []string + name string + channel constant.ProviderType + upstream string + wantKeys []string }{ {"Claude", constant.ProviderClaude, "claude-3-5-sonnet-20241022", []string{"messages", "max_tokens"}}, {"Gemini", constant.ProviderGemini, "gemini-2.0-flash", []string{"contents"}}, - {"Coze", constant.ProviderCoze, "bot-123", []string{"bot_id", "query"}}, - {"Dify", constant.ProviderDify, "dify-bot", []string{"query", "response_mode"}}, {"Ollama", constant.ProviderOllama, "llama3", []string{"model", "messages"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { info := newRequestTestRelayInfo(c.channel, c.upstream, constant.RelayModeChatCompletions) - r, ok := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)) - if !ok { - t.Fatal("expected conversion to succeed") + r, handled, err := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)) + if !handled || err != nil { + t.Fatalf("expected conversion to succeed, handled=%v err=%v", handled, err) } body := readAll(t, r) if len(body) == 0 { @@ -94,33 +92,33 @@ func TestConvertRequestViaRelaykit_AllProviders(t *testing.T) { func TestConvertRequestViaRelaykit_OllamaNonChatFallback(t *testing.T) { // Ollama 仅注册 chat 转换器;generate/embedding 模式应回退(ok=false) info := newRequestTestRelayInfo(constant.ProviderOllama, "llama3", constant.RelayModeEmbeddings) - if _, ok := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); ok { - t.Fatal("expected ok=false for Ollama non-chat mode") + if _, handled, _ := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); handled { + t.Fatal("expected handled=false for Ollama non-chat mode") } } func TestConvertRequestViaRelaykit_SameFormatFallback(t *testing.T) { // OpenAI 渠道 + OpenAI 入站:同格式 → 无转换器 → false info := newRequestTestRelayInfo(constant.ProviderOpenAI, "gpt-4", constant.RelayModeChatCompletions) - if _, ok := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); ok { - t.Fatal("expected ok=false for same format") + if _, handled, _ := convertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); handled { + t.Fatal("expected handled=false for same format") } } -func TestConvertRequestViaRelaykit_MalformedBodyFallback(t *testing.T) { +func TestConvertRequestViaRelaykit_MalformedBodyHardFail(t *testing.T) { info := newRequestTestRelayInfo(constant.ProviderClaude, "claude-x", constant.RelayModeChatCompletions) - if _, ok := convertRequestViaRelaykit(context.Background(), info, []byte(`not-json`)); ok { - t.Fatal("expected ok=false for malformed body") + if _, handled, err := convertRequestViaRelaykit(context.Background(), info, []byte(`not-json`)); !handled || err == nil { + t.Fatal("expected handled=true with error for malformed body (hard-fail)") } } func TestTryConvertRequestViaRelaykit_NilGuards(t *testing.T) { - if _, ok := tryConvertRequestViaRelaykit(context.Background(), nil, []byte(openAIRequestBody)); ok { - t.Fatal("expected ok=false for nil info") + if _, handled, _ := tryConvertRequestViaRelaykit(context.Background(), nil, []byte(openAIRequestBody)); handled { + t.Fatal("expected handled=false for nil info") } info := newRequestTestRelayInfo(constant.ProviderClaude, "claude-x", constant.RelayModeChatCompletions) info.ChannelMeta = nil - if _, ok := tryConvertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); ok { - t.Fatal("expected ok=false for nil ChannelMeta") + if _, handled, _ := tryConvertRequestViaRelaykit(context.Background(), info, []byte(openAIRequestBody)); handled { + t.Fatal("expected handled=false for nil ChannelMeta") } } diff --git a/relay/helper/error_message.go b/relay/helper/error_message.go index b850d19..992257c 100644 --- a/relay/helper/error_message.go +++ b/relay/helper/error_message.go @@ -2,11 +2,13 @@ package helper import ( "context" + "encoding/json" "errors" "io" "net" "net/url" "regexp" + "strings" "syscall" ) @@ -20,6 +22,63 @@ var ( redactIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) ) +// UnwrapUpstreamErrorMessage 从「上游错误响应体原文」中提取人类可读的错误消息。 +// +// 为什么需要:relay 各 adaptor 统一以 NewUpstreamError(status, string(body), nil) +// 上抛上游故障,Message 里是**整个上游错误响应体**。错误写出器若直接把它塞进 +// 客户端错误体的 message 字段,客户端看到的就是一坨转义 JSON +// (`{"error":{"message":"{\"error\":{\"message\":\"rate limit exceeded\"...`) +// 而不是可读消息——对用户是噪音,对按消息匹配的客户端逻辑是破坏。 +// +// 各协议错误信封形状不同,但消息位置固定: +// +// OpenAI / Responses {"error":{"message":"...","type":"..."}} +// Claude {"type":"error","error":{"type":"...","message":"..."}} +// Gemini {"error":{"code":N,"message":"...","status":"..."}}(偶为数组包装) +// Ollama {"error":"..."} ← error 为字符串 +// +// 非 JSON、或不是可识别的错误信封(例如网关自身的 "convert request (...)" 消息) +// 时原样返回,调用方可无条件套用。 +func UnwrapUpstreamErrorMessage(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) { + return raw // 非 JSON:网关自身消息等,原样返回 + } + + // Gemini 偶尔以单元素数组包装错误信封 + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &arr); err != nil || len(arr) == 0 { + return raw + } + trimmed = string(arr[0]) + } + + var envelope struct { + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal([]byte(trimmed), &envelope); err != nil || len(envelope.Error) == 0 { + return raw + } + + // Ollama 形态:error 直接是字符串 + var asString string + if err := json.Unmarshal(envelope.Error, &asString); err == nil { + if asString != "" { + return asString + } + return raw + } + + var detail struct { + Message string `json:"message"` + } + if err := json.Unmarshal(envelope.Error, &detail); err == nil && detail.Message != "" { + return detail.Message + } + return raw +} + // SafeUpstreamErrorMessage 把可能包含上游渠道域名 / 内部传输细节的错误, // 转换为对最终用户安全的消息文本。 // diff --git a/relay/helper/error_message_test.go b/relay/helper/error_message_test.go index 89d122e..2338aa1 100644 --- a/relay/helper/error_message_test.go +++ b/relay/helper/error_message_test.go @@ -36,15 +36,15 @@ func TestSafeUpstreamErrorMessage_TransportCategories(t *testing.T) { err error want string }{ - {"超时-context截止", leakyURLError(context.DeadlineExceeded), "upstream request timed out"}, - {"超时-i/o timeout", leakyURLError(fakeTimeoutError{}), "upstream request timed out"}, - {"客户端取消", leakyURLError(context.Canceled), "request canceled"}, - {"连接拒绝", leakyURLError(syscall.ECONNREFUSED), "upstream connection refused"}, - {"DNS失败", leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}), "upstream host resolution failed"}, - {"连接重置", leakyURLError(syscall.ECONNRESET), "upstream connection reset"}, - {"EOF", leakyURLError(io.EOF), "upstream connection reset"}, - {"意外EOF", leakyURLError(io.ErrUnexpectedEOF), "upstream connection reset"}, - {"其余传输层错误", leakyURLError(errors.New("tls: handshake failure")), "upstream connection failed"}, + {"超时-context截止", leakyURLError(context.DeadlineExceeded), "请求超时,请重试"}, + {"超时-i/o timeout", leakyURLError(fakeTimeoutError{}), "请求超时,请重试"}, + {"客户端取消", leakyURLError(context.Canceled), "请求已取消"}, + {"连接拒绝", leakyURLError(syscall.ECONNREFUSED), "服务暂时不可用,请稍后重试"}, + {"DNS失败", leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"}), "服务暂时不可用,请稍后重试"}, + {"连接重置", leakyURLError(syscall.ECONNRESET), "连接中断,请重试"}, + {"EOF", leakyURLError(io.EOF), "连接中断,请重试"}, + {"意外EOF", leakyURLError(io.ErrUnexpectedEOF), "连接中断,请重试"}, + {"其余传输层错误", leakyURLError(errors.New("tls: handshake failure")), "服务暂时不可用,请稍后重试"}, } for _, c := range cases { @@ -111,7 +111,7 @@ func TestSafeUpstreamErrorMessage_WrappedRelayError(t *testing.T) { leakyURLError(&net.DNSError{Err: "no such host", Name: "api.openai.com"})) got := SafeUpstreamErrorMessage(relayErr) - if got != "upstream host resolution failed" { + if got != "服务暂时不可用,请稍后重试" { t.Fatalf("经 RelayError 包装后归一化失败,got %q", got) } if strings.Contains(got, "openai.com") || strings.Contains(got, "https") { @@ -146,3 +146,92 @@ func TestRedactMessage(t *testing.T) { } } } + +// TestUnwrapUpstreamErrorMessage 上游错误信封解包:各协议形状都应提取出可读消息, +// 非错误信封的文本(网关自身消息)必须原样返回。 +func TestUnwrapUpstreamErrorMessage(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "OpenAI 信封", + raw: `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit"}}`, + want: "rate limit exceeded", + }, + { + name: "Claude 信封(顶层带 type)", + raw: `{"type":"error","error":{"type":"rate_limit_error","message":"number of request tokens has exceeded"}}`, + want: "number of request tokens has exceeded", + }, + { + name: "Gemini 信封", + raw: `{"error":{"code":429,"message":"Resource has been exhausted","status":"RESOURCE_EXHAUSTED"}}`, + want: "Resource has been exhausted", + }, + { + name: "Gemini 数组包装", + raw: `[{"error":{"code":400,"message":"API key not valid","status":"INVALID_ARGUMENT"}}]`, + want: "API key not valid", + }, + { + name: "Ollama 信封(error 为字符串)", + raw: `{"error":"model 'llama3' not found"}`, + want: "model 'llama3' not found", + }, + { + name: "网关自身消息(非 JSON)原样返回", + raw: "convert request (openai_to_claude): unexpected content part", + want: "convert request (openai_to_claude): unexpected content part", + }, + { + name: "空字符串", + raw: "", + want: "", + }, + { + name: "畸形 JSON 原样返回", + raw: `{"error":{"message":`, + want: `{"error":{"message":`, + }, + { + name: "无 error 字段的 JSON 原样返回", + raw: `{"detail":"something else"}`, + want: `{"detail":"something else"}`, + }, + { + name: "HTML 错误页原样返回", + raw: "502 Bad Gateway", + want: "502 Bad Gateway", + }, + { + name: "error.message 为空时回退原文", + raw: `{"error":{"type":"server_error"}}`, + want: `{"error":{"type":"server_error"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := UnwrapUpstreamErrorMessage(tt.raw); got != tt.want { + t.Errorf("UnwrapUpstreamErrorMessage(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +// TestUnwrapUpstreamErrorMessage_NotDoubleEncoded 回归守卫:解包结果不得再是 JSON 信封。 +// 这是本函数存在的原因——客户端曾收到 message 字段里嵌一整个转义的上游错误 JSON。 +func TestUnwrapUpstreamErrorMessage_NotDoubleEncoded(t *testing.T) { + for _, raw := range []string{ + `{"error":{"message":"rate limit exceeded","type":"rate_limit_error"}}`, + `{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded"}}`, + `{"error":{"code":429,"message":"rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}`, + } { + got := UnwrapUpstreamErrorMessage(raw) + if strings.Contains(got, `"error"`) || strings.Contains(got, "{") { + t.Errorf("解包后仍含 JSON 信封结构: %q(输入 %q)", got, raw) + } + } +} diff --git a/relay/helper/provider_format.go b/relay/helper/provider_format.go index 73b3396..639220f 100644 --- a/relay/helper/provider_format.go +++ b/relay/helper/provider_format.go @@ -1,6 +1,11 @@ package helper -import "github.com/qianfree/team-api/relay/constant" +import ( + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) // providerNativeFormat 在 relaykit 迁移期间曾被复制到多个包(handler/passthrough.go、 // handler/relaykit_bridge.go、relaykit_bridge/response.go)以规避循环引用。 @@ -8,21 +13,63 @@ import "github.com/qianfree/team-api/relay/constant" // 各调用方改为 import 本包调用,消除「多副本需保持同步」的维护隐患。 // ProviderNativeFormat 根据 ProviderType 返回上游的原生请求格式。 -// 用于直连判定(canPassThrough)、系统提示词注入格式选择(InjectSystemPrompt)、 -// relaykit 桥接的转换方向推断等所有场景。 +// +// 注意:本函数只看渠道类型。**模型名驱动的双协议上游(Vertex AI)必须改用 +// ProviderNativeFormatFor / ProviderNativeFormatForModel**,否则会判成 openai +// 而把请求体转成 chat 发到原生端点(见下方 VertexModelFormat 的说明)。 +// 仅在确实没有模型信息、或已知供应商协议与模型无关时才直接调用本函数。 func ProviderNativeFormat(providerType int) constant.RelayFormat { switch constant.ProviderType(providerType) { case constant.ProviderClaude: return constant.RelayFormatClaude case constant.ProviderGemini: return constant.RelayFormatGemini - case constant.ProviderCoze: - return constant.RelayFormatCoze - case constant.ProviderDify: - return constant.RelayFormatDify case constant.ProviderOllama: return constant.RelayFormatOllama default: return constant.RelayFormatOpenAI } } + +// VertexModelFormat 判定 Vertex AI 渠道在给定模型下的上游原生协议。 +// +// Vertex AI 是**模型名驱动的双协议上游**:同一渠道既可挂 Gemini 模型 +// (publishers/google/{model}:generateContent,吃 Gemini 原生体), +// 也可挂 Claude 模型(publishers/anthropic/{model}:rawPredict,吃 Claude 原生体)。 +// 这与「每渠道一种协议」的 ProviderNativeFormat 模型不匹配——按渠道类型判会落到 +// default 分支得出 openai,于是矩阵把请求体转成 chat 发到原生端点、上游必然 400; +// 而 Vertex 的 GetRequestURL 不按 RelayMode 报错,矩阵守卫测试也发现不了 +// (体与端点错配只有 E2E 层能捕获)。 +// +// 判据与 vertex adaptor 选择委托适配器 / 构造 URL 的判据**必须是同一个**, +// 因此权威实现放在这里,adaptor 侧调用本函数,杜绝两处漂移。 +func VertexModelFormat(model string) constant.RelayFormat { + if strings.Contains(strings.ToLower(model), "claude") { + return constant.RelayFormatClaude + } + return constant.RelayFormatGemini +} + +// ProviderNativeFormatForModel 在 ProviderNativeFormat 基础上叠加模型名维度。 +// 除 Vertex 外的供应商协议与模型无关,直接委托 ProviderNativeFormat。 +func ProviderNativeFormatForModel(providerType int, upstreamModel string) constant.RelayFormat { + if constant.ProviderType(providerType) == constant.ProviderVertex { + return VertexModelFormat(upstreamModel) + } + return ProviderNativeFormat(providerType) +} + +// ProviderNativeFormatFor 返回本次请求的上游原生协议格式(调用方首选入口)。 +// +// 模型名取 UpstreamModelName(与 adaptor 构造 URL 所用的字段一致,保证 +// 「判定协议」与「选端点」基于同一个模型名);为空时回落客户端请求的模型名。 +func ProviderNativeFormatFor(info *common.RelayInfo) constant.RelayFormat { + if info == nil || info.ChannelMeta == nil { + return constant.RelayFormatOpenAI + } + model := info.ChannelMeta.UpstreamModelName + if model == "" { + model = info.OriginModelName + } + return ProviderNativeFormatForModel(info.ChannelMeta.ChannelType, model) +} diff --git a/relay/helper/provider_format_test.go b/relay/helper/provider_format_test.go index 87c4899..2840c3f 100644 --- a/relay/helper/provider_format_test.go +++ b/relay/helper/provider_format_test.go @@ -3,6 +3,7 @@ package helper import ( "testing" + "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" ) @@ -18,8 +19,6 @@ func TestProviderNativeFormat(t *testing.T) { }{ {"claude", int(constant.ProviderClaude), constant.RelayFormatClaude}, {"gemini", int(constant.ProviderGemini), constant.RelayFormatGemini}, - {"coze", int(constant.ProviderCoze), constant.RelayFormatCoze}, - {"dify", int(constant.ProviderDify), constant.RelayFormatDify}, {"ollama", int(constant.ProviderOllama), constant.RelayFormatOllama}, {"openai", int(constant.ProviderOpenAI), constant.RelayFormatOpenAI}, // OpenAI 兼容供应商无原生格式,默认走 OpenAI @@ -38,3 +37,83 @@ func TestProviderNativeFormat(t *testing.T) { }) } } + +// TestProviderNativeFormat_VertexIsModelDriven 钉住「按渠道类型判 vertex 会得出 openai」 +// 这一事实本身——它正是本次 bug 的根源。vertex 的正确判定必须走带模型名的入口。 +func TestProviderNativeFormat_VertexIsModelDriven(t *testing.T) { + if got := ProviderNativeFormat(int(constant.ProviderVertex)); got != constant.RelayFormatOpenAI { + t.Fatalf("前提变化:ProviderNativeFormat(vertex) = %s;若已改为模型无关的固定格式,请同步修订 ProviderNativeFormatForModel", got) + } + // 同一渠道类型、不同模型 → 不同上游协议 + if got := ProviderNativeFormatForModel(int(constant.ProviderVertex), "gemini-2.5-pro"); got != constant.RelayFormatGemini { + t.Errorf("vertex + gemini 模型 = %s, want %s", got, constant.RelayFormatGemini) + } + if got := ProviderNativeFormatForModel(int(constant.ProviderVertex), "claude-sonnet-4-5"); got != constant.RelayFormatClaude { + t.Errorf("vertex + claude 模型 = %s, want %s", got, constant.RelayFormatClaude) + } +} + +func TestVertexModelFormat(t *testing.T) { + tests := []struct { + model string + want constant.RelayFormat + }{ + {"claude-sonnet-4-5", constant.RelayFormatClaude}, + {"claude-3-7-sonnet@20250219", constant.RelayFormatClaude}, + {"CLAUDE-OPUS-4", constant.RelayFormatClaude}, // 大小写不敏感 + {"gemini-2.5-pro", constant.RelayFormatGemini}, + {"gemini-2.0-flash", constant.RelayFormatGemini}, + {"text-embedding-004", constant.RelayFormatGemini}, // 非 claude 一律按 Gemini 端点 + {"", constant.RelayFormatGemini}, // 空模型名兜底 Gemini(与 adaptor 选端点口径一致) + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + if got := VertexModelFormat(tt.model); got != tt.want { + t.Errorf("VertexModelFormat(%q) = %s, want %s", tt.model, got, tt.want) + } + }) + } +} + +// TestProviderNativeFormatForModel_NonVertexIgnoresModel 非 vertex 供应商的协议与模型无关: +// 模型名里含 "claude" 不得影响判定(否则 OpenAI 渠道上的 claude 兼容模型会被判成 Claude 协议)。 +func TestProviderNativeFormatForModel_NonVertexIgnoresModel(t *testing.T) { + for _, p := range []constant.ProviderType{ + constant.ProviderOpenAI, constant.ProviderDeepSeek, constant.ProviderAli, + } { + if got := ProviderNativeFormatForModel(int(p), "claude-sonnet-4-5"); got != constant.RelayFormatOpenAI { + t.Errorf("provider %s + claude 模型 = %s, want %s(非 vertex 不应看模型名)", p, got, constant.RelayFormatOpenAI) + } + } + if got := ProviderNativeFormatForModel(int(constant.ProviderGemini), "claude-sonnet-4-5"); got != constant.RelayFormatGemini { + t.Errorf("gemini 渠道 = %s, want %s", got, constant.RelayFormatGemini) + } +} + +func TestProviderNativeFormatFor(t *testing.T) { + mk := func(provider constant.ProviderType, upstreamModel, originModel string) *common.RelayInfo { + return &common.RelayInfo{ + OriginModelName: originModel, + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(provider), + UpstreamModelName: upstreamModel, + }, + } + } + + // 取 UpstreamModelName(与 adaptor 构造 URL 所用字段一致) + if got := ProviderNativeFormatFor(mk(constant.ProviderVertex, "claude-sonnet-4-5", "gemini-pro")); got != constant.RelayFormatClaude { + t.Errorf("应按 UpstreamModelName 判定, got %s", got) + } + // UpstreamModelName 为空时回落客户端模型名 + if got := ProviderNativeFormatFor(mk(constant.ProviderVertex, "", "claude-sonnet-4-5")); got != constant.RelayFormatClaude { + t.Errorf("UpstreamModelName 为空应回落 OriginModelName, got %s", got) + } + // nil 守卫 + if got := ProviderNativeFormatFor(nil); got != constant.RelayFormatOpenAI { + t.Errorf("nil info = %s, want %s", got, constant.RelayFormatOpenAI) + } + if got := ProviderNativeFormatFor(&common.RelayInfo{}); got != constant.RelayFormatOpenAI { + t.Errorf("nil ChannelMeta = %s, want %s", got, constant.RelayFormatOpenAI) + } +} diff --git a/relay/helper/system_prompt.go b/relay/helper/system_prompt.go index 11584b1..101af95 100644 --- a/relay/helper/system_prompt.go +++ b/relay/helper/system_prompt.go @@ -15,7 +15,10 @@ func InjectSystemPrompt(body []byte, info *common.RelayInfo) []byte { return body } - nativeFormat := ProviderNativeFormat(info.ChannelMeta.ChannelType) + // 注入格式必须与**转换后**的请求体格式一致(本函数在格式转换之后调用)。 + // 用模型维度判定:Vertex 渠道的体是 Gemini / Claude 原生格式,按渠道类型 + // 判成 openai 会把 messages 数组注进一个根本没有 messages 字段的体里 + nativeFormat := ProviderNativeFormatFor(info) switch nativeFormat { case constant.RelayFormatClaude: return injectSystemPromptClaude(body, prompt, info.ChannelMeta.Settings.SystemPromptOverride) diff --git a/relay/helper/thinking.go b/relay/helper/thinking.go deleted file mode 100644 index c263056..0000000 --- a/relay/helper/thinking.go +++ /dev/null @@ -1,58 +0,0 @@ -package helper - -import "strings" - -// ThinkingInfo 从模型名解析出的 thinking 配置 -type ThinkingInfo struct { - BaseModel string // 去除后缀的基础模型名 - IsThinking bool // 是否有 -thinking 后缀 - EffortLevel string // effort 级别:low/medium/high/xhigh/max/minimal - IsNoThinking bool // 是否有 -nothinking 后缀 -} - -// 后缀列表,按长度降序排列以避免误匹配(如 -minimal 优先于 -min) -var effortSuffixes = []struct { - suffix string - level string -}{ - {"-minimal", "minimal"}, - {"-medium", "medium"}, - {"-xhigh", "xhigh"}, - {"-high", "high"}, - {"-low", "low"}, - {"-max", "max"}, -} - -// ParseThinkingSuffix 从模型名解析 thinking 和 effort 后缀 -func ParseThinkingSuffix(modelName string) ThinkingInfo { - info := ThinkingInfo{} - - if strings.HasSuffix(modelName, "-thinking") { - info.IsThinking = true - info.BaseModel = modelName[:len(modelName)-len("-thinking")] - return info - } - - if strings.HasSuffix(modelName, "-nothinking") { - info.IsNoThinking = true - info.BaseModel = modelName[:len(modelName)-len("-nothinking")] - return info - } - - if strings.HasSuffix(modelName, "-none") { - info.IsNoThinking = true - info.BaseModel = modelName[:len(modelName)-len("-none")] - return info - } - - for _, s := range effortSuffixes { - if strings.HasSuffix(modelName, s.suffix) { - info.EffortLevel = s.level - info.BaseModel = modelName[:len(modelName)-len(s.suffix)] - return info - } - } - - info.BaseModel = modelName - return info -} diff --git a/relay/helper/thinking_test.go b/relay/helper/thinking_test.go deleted file mode 100644 index 7c4b9f6..0000000 --- a/relay/helper/thinking_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package helper - -import "testing" - -func TestParseThinkingSuffix(t *testing.T) { - tests := []struct { - input string - wantBase string - wantThink bool - wantEffort string - wantNoThink bool - }{ - {"gpt-4o", "gpt-4o", false, "", false}, - {"claude-3-5-sonnet-thinking", "claude-3-5-sonnet", true, "", false}, - {"gpt-4o-nothinking", "gpt-4o", false, "", true}, - {"claude-3-opus-high", "claude-3-opus", false, "high", false}, - {"gpt-4o-low", "gpt-4o", false, "low", false}, - {"gemini-2.5-pro-medium", "gemini-2.5-pro", false, "medium", false}, - {"o3-xhigh", "o3", false, "xhigh", false}, - {"o3-max", "o3", false, "max", false}, - {"gemini-2.5-flash-minimal", "gemini-2.5-flash", false, "minimal", false}, - {"claude-opus-4-20250514-thinking", "claude-opus-4-20250514", true, "", false}, - {"gpt-4o-0613", "gpt-4o-0613", false, "", false}, // 非思考后缀 - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - info := ParseThinkingSuffix(tt.input) - if info.BaseModel != tt.wantBase { - t.Errorf("BaseModel = %q, want %q", info.BaseModel, tt.wantBase) - } - if info.IsThinking != tt.wantThink { - t.Errorf("IsThinking = %v, want %v", info.IsThinking, tt.wantThink) - } - if info.EffortLevel != tt.wantEffort { - t.Errorf("EffortLevel = %q, want %q", info.EffortLevel, tt.wantEffort) - } - if info.IsNoThinking != tt.wantNoThink { - t.Errorf("IsNoThinking = %v, want %v", info.IsNoThinking, tt.wantNoThink) - } - }) - } -} diff --git a/relay/relaykit_bridge/response.go b/relay/relaykit_bridge/response.go index 879a554..f42d6f3 100644 --- a/relay/relaykit_bridge/response.go +++ b/relay/relaykit_bridge/response.go @@ -3,6 +3,9 @@ package relaykit_bridge import ( "context" "encoding/json" + "errors" + "fmt" + "net/http" "time" "github.com/gogf/gf/v2/frame/g" @@ -10,76 +13,48 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" - "github.com/qianfree/team-api/relay/helper" "github.com/qianfree/team-api/relaykit/relayconvert" ) // TryConvertResponseViaRelaykit 尝试用 relaykit 转换器转换非流式响应。 -// 成功返回 (转换后的响应体, Usage, true);无匹配 / 转换失败返回 (nil, nil, false)。 +// +// 返回值语义(hard-fail 模型,无 legacy 回退): +// - handled=false, err=nil:该方向无 relaykit 转换器(同格式直连 / 未覆盖模式), +// 调用方走透传或自有处理; +// - handled=true, err!=nil:方向匹配但解析/转换失败——协议转换是该方向的唯一路径, +// 调用方应向上返回错误(不再回退旧实现); +// - handled=true, err=nil:转换成功,converted 为客户端格式响应体。 // // 结构与流式桥接对称:nil 守卫留在公开入口,转换逻辑抽到 config-free 的 // convertResponseViaRelaykit 核心以便单测直接覆盖。 -func TryConvertResponseViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody []byte) ([]byte, *dto.Usage, bool) { +func TryConvertResponseViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody []byte) (converted []byte, usage *dto.Usage, handled bool, err error) { if info == nil || info.ChannelMeta == nil { - return nil, nil, false + return nil, nil, false, nil } return convertResponseViaRelaykit(ctx, info, upstreamBody) } -// convertResponseViaRelaykit 是非流式响应转换的 config-free 核心(特性开关已由调用方校验)。 +// convertResponseViaRelaykit 是非流式响应转换的 config-free 核心。 // info 与 info.ChannelMeta 必须非空。 -func convertResponseViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody []byte) ([]byte, *dto.Usage, bool) { +func convertResponseViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody []byte) ([]byte, *dto.Usage, bool, error) { // 响应转换方向:上游格式 → 客户端格式(与请求相反) - upstream := helper.ProviderNativeFormat(info.ChannelMeta.ChannelType) + upstream := EffectiveUpstreamFormat(info) clientFormat := info.GetOriginalClientFormat() - converterID := relaykitResponseConverterID(upstream, clientFormat) + converterID := ResponseConverterIDForRoute(upstream, clientFormat) if converterID == "" { - return nil, nil, false + return nil, nil, false, nil } spec, ok := relayconvert.LookupTextConverter(converterID) if !ok || spec.Resp.Convert == nil { - return nil, nil, false + return nil, nil, false, nil } - // 解析上游响应体为对应 DTO(Claude → dto.ClaudeResponse;Gemini → dto.GeminiChatResponse; - // Dify → dto.DifyBlockingResponse;Ollama → dto.OllamaChatResponse)。 - // Coze 上游始终为 SSE(非流式客户端也走流式),宿主已缓冲整段 SSE,直接把原始 []byte 交给转换器解析。 - var upstreamResp any - switch upstream { - case constant.RelayFormatClaude: - var claudeResp dto.ClaudeResponse - if err := json.Unmarshal(upstreamBody, &claudeResp); err != nil { - g.Log().Warningf(ctx, "[relaykit] parse Claude response failed, fallback to legacy: %v", err) - return nil, nil, false - } - upstreamResp = &claudeResp - case constant.RelayFormatGemini: - var geminiResp dto.GeminiChatResponse - if err := json.Unmarshal(upstreamBody, &geminiResp); err != nil { - g.Log().Warningf(ctx, "[relaykit] parse Gemini response failed, fallback to legacy: %v", err) - return nil, nil, false - } - upstreamResp = &geminiResp - case constant.RelayFormatCoze: - // 原始缓冲 SSE 体,由 CozeToOpenAIResponseConverter 解析 - upstreamResp = upstreamBody - case constant.RelayFormatDify: - var difyResp dto.DifyBlockingResponse - if err := json.Unmarshal(upstreamBody, &difyResp); err != nil { - g.Log().Warningf(ctx, "[relaykit] parse Dify response failed, fallback to legacy: %v", err) - return nil, nil, false - } - upstreamResp = &difyResp - case constant.RelayFormatOllama: - var ollamaResp dto.OllamaChatResponse - if err := json.Unmarshal(upstreamBody, &ollamaResp); err != nil { - g.Log().Warningf(ctx, "[relaykit] parse Ollama response failed, fallback to legacy: %v", err) - return nil, nil, false - } - upstreamResp = &ollamaResp - default: - return nil, nil, false + // 解析上游响应体为对应格式 DTO。 + upstreamResp, err := parseUpstreamResponse(upstream, upstreamBody) + if err != nil { + g.Log().Warningf(ctx, "[relaykit] parse %s response failed (converter=%s): %v", upstream, converterID, err) + return nil, nil, true, fmt.Errorf("parse upstream %s response: %w", upstream, err) } start := time.Now() @@ -87,17 +62,55 @@ func convertResponseViaRelaykit(ctx context.Context, info *common.RelayInfo, ups duration := time.Since(start) monitor.TrackConverterCall(converterID, string(upstream), string(clientFormat), duration, err) if err != nil { - g.Log().Warningf(ctx, "[relaykit] convert response failed (converter=%s), fallback to legacy: %v", converterID, err) - return nil, nil, false + g.Log().Warningf(ctx, "[relaykit] convert response failed (converter=%s): %v", converterID, err) + return nil, nil, true, fmt.Errorf("convert response (%s): %w", converterID, err) } out, err := json.Marshal(converted) if err != nil { - g.Log().Warningf(ctx, "[relaykit] marshal converted response failed (converter=%s), fallback to legacy: %v", converterID, err) - return nil, nil, false + g.Log().Warningf(ctx, "[relaykit] marshal converted response failed (converter=%s): %v", converterID, err) + return nil, nil, true, fmt.Errorf("marshal converted response (%s): %w", converterID, err) } - return out, usage, true + return out, usage, true, nil +} + +// parseUpstreamResponse 按上游格式把响应体解析为对应 DTO 指针。 +func parseUpstreamResponse(upstream constant.RelayFormat, body []byte) (any, error) { + switch upstream { + case constant.RelayFormatClaude: + var claudeResp dto.ClaudeResponse + if err := json.Unmarshal(body, &claudeResp); err != nil { + return nil, err + } + return &claudeResp, nil + case constant.RelayFormatGemini: + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal(body, &geminiResp); err != nil { + return nil, err + } + return &geminiResp, nil + case constant.RelayFormatOllama: + var ollamaResp dto.OllamaChatResponse + if err := json.Unmarshal(body, &ollamaResp); err != nil { + return nil, err + } + return &ollamaResp, nil + case constant.RelayFormatOpenAI: + var chatResp dto.ChatCompletionResponse + if err := json.Unmarshal(body, &chatResp); err != nil { + return nil, err + } + return &chatResp, nil + case constant.RelayFormatResponses: + var responsesResp dto.OpenAIResponsesResponse + if err := json.Unmarshal(body, &responsesResp); err != nil { + return nil, err + } + return &responsesResp, nil + default: + return nil, fmt.Errorf("unsupported upstream format %q", upstream) + } } // UsageFromConvertedChatResponse 从 relaykit 转换后的 OpenAI ChatCompletionResponse 响应体中提取用量。 @@ -128,24 +141,17 @@ func convertedCacheCreationTokens(details *dto.TokenDetails) int { return details.CachedCreationTokens } -// relaykitResponseConverterID 根据 (上游原生格式, 客户端格式) 返回响应转换器 ID。 -// 返回空串表示没有匹配的 relaykit 响应转换器(调用方回退旧路径)。 -func relaykitResponseConverterID(upstream, clientFormat constant.RelayFormat) string { - if upstream == clientFormat { - return "" // 同格式无需转换 - } - switch { - case upstream == constant.RelayFormatClaude && clientFormat == constant.RelayFormatOpenAI: - return relayconvert.ConverterOpenAIChatToClaudeMessages // 同一个 spec,响应侧是反向(Claude→OpenAI) - case upstream == constant.RelayFormatGemini && clientFormat == constant.RelayFormatOpenAI: - return relayconvert.ConverterOpenAIChatToGeminiContent // 同理,响应侧是 Gemini→OpenAI - case upstream == constant.RelayFormatCoze && clientFormat == constant.RelayFormatOpenAI: - return relayconvert.ConverterOpenAIChatToCoze // 响应侧是 Coze→OpenAI - case upstream == constant.RelayFormatDify && clientFormat == constant.RelayFormatOpenAI: - return relayconvert.ConverterOpenAIChatToDify // 响应侧是 Dify→OpenAI - case upstream == constant.RelayFormatOllama && clientFormat == constant.RelayFormatOpenAI: - return relayconvert.ConverterOpenAIChatToOllama // 响应侧是 Ollama→OpenAI - default: - return "" +// ResponseConvertError 把非流式响应转换错误映射为宿主错误类型。 +// +// 与流式侧 streamConvertError 同一套分类依据,区别是非流式尚未写出任何字节, +// 因此不置 ResponseWritten(上层仍可写标准错误体,甚至换渠道重试): +// - 内容安全拦截(ErrContentBlocked)是客户端提示词问题,映射为请求类错误, +// 避免正常渠道因用户违规内容被扣健康分/熔断; +// - 其余为上游响应体非法,沿用上游状态码与 Retry-After 头按上游错误上报。 +func ResponseConvertError(convErr error, upstreamStatus int, header http.Header) error { + if errors.Is(convErr, relayconvert.ErrContentBlocked) { + return constant.NewRequestError(convErr.Error(), convErr) } + return constant.NewUpstreamError(upstreamStatus, "invalid response body", convErr). + WithRetryAfter(constant.RetryAfterFromHeader(header)) } diff --git a/relay/relaykit_bridge/response_test.go b/relay/relaykit_bridge/response_test.go index b63f688..3cc2568 100644 --- a/relay/relaykit_bridge/response_test.go +++ b/relay/relaykit_bridge/response_test.go @@ -13,21 +13,19 @@ import ( // --- 纯辅助函数表驱动 --- -func TestRelaykitResponseConverterID(t *testing.T) { +func TestResponseConverterIDForRoute(t *testing.T) { cases := []struct { upstream, client constant.RelayFormat want string }{ {constant.RelayFormatClaude, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToClaudeMessages}, {constant.RelayFormatGemini, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToGeminiContent}, - {constant.RelayFormatCoze, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToCoze}, - {constant.RelayFormatDify, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToDify}, {constant.RelayFormatOllama, constant.RelayFormatOpenAI, relayconvert.ConverterOpenAIChatToOllama}, - {constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, ""}, // 同格式 - {constant.RelayFormatClaude, constant.RelayFormatGemini, ""}, // 未知方向 + {constant.RelayFormatOpenAI, constant.RelayFormatOpenAI, ""}, // 同格式 + {constant.RelayFormatClaude, constant.RelayFormatGemini, relayconvert.ConverterGeminiContentToClaudeMessages}, // 跨原生:Gemini 客户端·Claude 上游 } for _, c := range cases { - if got := relaykitResponseConverterID(c.upstream, c.client); got != c.want { + if got := ResponseConverterIDForRoute(c.upstream, c.client); got != c.want { t.Errorf("upstream=%s client=%s: got %q, want %q", c.upstream, c.client, got, c.want) } } @@ -81,32 +79,20 @@ func TestConvertResponseViaRelaykit_AllProviders(t *testing.T) { upstreamBody: `{"candidates":[{"content":{"role":"model","parts":[{"text":"hi"}]},` + `"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3}}`, }, - { - name: "Dify", - channel: constant.ProviderDify, - upstreamBody: `{"answer":"hi","metadata":{"usage":{"total_tokens":8,` + - `"prompt_tokens":5,"completion_tokens":3}}}`, - }, { name: "Ollama", channel: constant.ProviderOllama, upstreamBody: `{"model":"llama3","message":{"role":"assistant","content":"hi"},` + `"done":true,"prompt_eval_count":5,"eval_count":3}`, }, - { - name: "Coze", - channel: constant.ProviderCoze, - upstreamBody: "event: conversation.message.completed\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n", - }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { info := newStreamTestRelayInfo(c.channel, constant.RelayFormatOpenAI) - body, usage, ok := convertResponseViaRelaykit(context.Background(), info, []byte(c.upstreamBody)) - if !ok { - t.Fatal("expected conversion to succeed") + body, usage, handled, err := convertResponseViaRelaykit(context.Background(), info, []byte(c.upstreamBody)) + if !handled || err != nil { + t.Fatalf("expected conversion to succeed, handled=%v err=%v", handled, err) } if body == nil { t.Fatal("expected non-nil converted body") @@ -132,30 +118,28 @@ func TestConvertResponseViaRelaykit_AllProviders(t *testing.T) { func TestConvertResponseViaRelaykit_SameFormatFallback(t *testing.T) { // OpenAI 渠道 + OpenAI 客户端:upstream==client → 无转换器 → false info := newStreamTestRelayInfo(constant.ProviderOpenAI, constant.RelayFormatOpenAI) - if _, _, ok := convertResponseViaRelaykit(context.Background(), info, []byte(`{}`)); ok { - t.Fatal("expected ok=false for same format") + if _, _, handled, _ := convertResponseViaRelaykit(context.Background(), info, []byte(`{}`)); handled { + t.Fatal("expected handled=false for same format") } } func TestConvertResponseViaRelaykit_ParseFailureFallback(t *testing.T) { // Claude 渠道但上游体非合法 Claude JSON → 回退 info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI) - if _, _, ok := convertResponseViaRelaykit(context.Background(), info, []byte(`not-json`)); ok { - t.Fatal("expected ok=false for malformed upstream body") + if _, _, handled, err := convertResponseViaRelaykit(context.Background(), info, []byte(`not-json`)); !handled || err == nil { + t.Fatal("expected handled=true with error for malformed upstream body (hard-fail)") } } func TestConvertResponseViaRelaykit_ParseFailureAllStructuredProviders(t *testing.T) { // 结构化响应(需 json.Unmarshal)的供应商:畸形上游体都应回退(覆盖各自的 parse-fail 分支)。 - // Coze 上游为原始 SSE 字节(无 Unmarshal),不在此列。 structured := []constant.ProviderType{ - constant.ProviderClaude, constant.ProviderGemini, - constant.ProviderDify, constant.ProviderOllama, + constant.ProviderClaude, constant.ProviderGemini, constant.ProviderOllama, } for _, ch := range structured { info := newStreamTestRelayInfo(ch, constant.RelayFormatOpenAI) - if _, _, ok := convertResponseViaRelaykit(context.Background(), info, []byte(`{not valid json`)); ok { - t.Errorf("provider=%v: expected ok=false for malformed body", ch) + if _, _, handled, err := convertResponseViaRelaykit(context.Background(), info, []byte(`{not valid json`)); !handled || err == nil { + t.Errorf("provider=%v: expected handled=true with error for malformed body", ch) } } } @@ -163,12 +147,12 @@ func TestConvertResponseViaRelaykit_ParseFailureAllStructuredProviders(t *testin // --- 公开入口 nil 守卫 --- func TestTryConvertResponseViaRelaykit_NilGuards(t *testing.T) { - if _, _, ok := TryConvertResponseViaRelaykit(context.Background(), nil, []byte(`{}`)); ok { - t.Fatal("expected ok=false for nil info") + if _, _, handled, _ := TryConvertResponseViaRelaykit(context.Background(), nil, []byte(`{}`)); handled { + t.Fatal("expected handled=false for nil info") } info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI) info.ChannelMeta = nil - if _, _, ok := TryConvertResponseViaRelaykit(context.Background(), info, []byte(`{}`)); ok { - t.Fatal("expected ok=false for nil ChannelMeta") + if _, _, handled, _ := TryConvertResponseViaRelaykit(context.Background(), info, []byte(`{}`)); handled { + t.Fatal("expected handled=false for nil ChannelMeta") } } diff --git a/relay/relaykit_bridge/route.go b/relay/relaykit_bridge/route.go new file mode 100644 index 0000000..403b7e7 --- /dev/null +++ b/relay/relaykit_bridge/route.go @@ -0,0 +1,119 @@ +package relaykit_bridge + +import ( + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/helper" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/types" + + // blank import 触发内置转换器注册(register.init() 调用 RegisterTextConverter + // 与 RegisterStreamConverter)。放在桥接包而非某个调用方,保证任何使用桥接的包 + //(含各 channel 包及其单测)都能拿到已注册的转换器。 + _ "github.com/qianfree/team-api/relaykit/relayconvert/register" +) + +// 本文件是宿主与 relaykit 之间「转换方向 → 转换器 ID」的唯一权威映射。 +// 请求桥(relay/handler)与响应桥(本包)共用,避免矩阵多副本漂移。 + +// KitFormat 宿主 RelayFormat → relaykit types.RelayFormat。 +// 除 Responses 外字符串值一致可直接强转;Responses 两侧命名不同 +// (宿主 "responses" vs relaykit "openai_responses"),必须显式映射。 +func KitFormat(f constant.RelayFormat) types.RelayFormat { + if f == constant.RelayFormatResponses { + return types.RelayFormatOpenAIResponses + } + return types.RelayFormat(f) +} + +// EffectiveUpstreamFormat 计算本次请求的有效上游协议格式。 +// 基础值取渠道供应商原生格式;OpenAI 主协议的渠道有三种覆盖: +// - Claude 入站 + 供应商另挂 Anthropic 兼容端点:上游实际说 Claude(同格式 → 无转换); +// - UseResponsesAPI:chat 入站经 /v1/responses 桥接(渠道 ChatViaResponses 配置); +// - Responses 入站 + 上游原生支持 Responses:保持 Responses 直连(同格式 → 无转换)。 +func EffectiveUpstreamFormat(info *common.RelayInfo) constant.RelayFormat { + // 取模型维度的原生格式:Vertex 是模型名驱动的双协议上游(Gemini / Claude), + // 按渠道类型判会落到 openai 并让矩阵把体转成 chat 发到原生端点 + upstream := helper.ProviderNativeFormatFor(info) + if upstream != constant.RelayFormatOpenAI { + return upstream + } + // Anthropic 兼容端点覆盖:这类渠道 GetRequestURL 对 ClaudeMessages 返回独立的 + // /anthropic/v1/messages 类地址,请求与响应两侧都是 Claude 口径。若按主协议 + // 判成 OpenAI,会把 Claude 体转成 chat 发到 Anthropic 端点、再把 Claude 响应 + // 当 OpenAI 解析,两侧同时断链。 + if info.InboundFormat == constant.RelayFormatClaude && + constant.HasNativeClaudeEndpoint(info.ChannelMeta.ChannelType) { + return constant.RelayFormatClaude + } + if info.UseResponsesAPI { + return constant.RelayFormatResponses + } + if info.InboundFormat == constant.RelayFormatResponses && info.ChannelMeta.UpstreamSpeaksResponses() { + return constant.RelayFormatResponses + } + // 多协议原生透传渠道覆盖:上游(New API / Sub2API)同时原生支持 OpenAI/Claude/Gemini, + // 三种入站一律同格式直达(与 inboundMatchesChannelNative 的判定口径一致)。 + // 若按主协议判成 OpenAI,配置了模型映射/参数改写等强制走转换路径的场景会把 + // Claude/Gemini 体转成 chat,而 multinative 按入站格式选原生端点(/v1/messages、 + // :generateContent),体与端点错配、上游 400。模型名替换等由 multinative 委托的 + // 原生 adaptor 在同格式内完成(adaptor.ConvertRequest 路径)。 + if constant.IsMultiNativeProvider(info.ChannelMeta.ChannelType) { + switch info.InboundFormat { + case constant.RelayFormatOpenAI, constant.RelayFormatClaude, constant.RelayFormatGemini: + return info.InboundFormat + } + } + return upstream +} + +// RequestConverterIDForRoute 按 (入站格式, 有效上游格式, RelayMode) 返回配对转换器 ID。 +// 返回空串表示该方向无 relaykit 转换器(同格式直连、或该模式未覆盖)。 +// Ollama 仅注册了 chat 路径转换器,generate/embedding 模式返回空串。 +func RequestConverterIDForRoute(inbound, upstream constant.RelayFormat, relayMode int) string { + if inbound == upstream { + return "" // 同格式无需转换(这类请求走 passthrough) + } + switch { + // OpenAI 入站 → 原生上游 + case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatClaude: + return relayconvert.ConverterOpenAIChatToClaudeMessages + case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatGemini: + return relayconvert.ConverterOpenAIChatToGeminiContent + case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatOllama: + if constant.RelayMode(relayMode) != constant.RelayModeChatCompletions { + return "" + } + return relayconvert.ConverterOpenAIChatToOllama + case inbound == constant.RelayFormatOpenAI && upstream == constant.RelayFormatResponses: + return relayconvert.ConverterOpenAIChatToOpenAIResponses + + // 非 OpenAI 入站 → OpenAI 上游(反向方向) + case inbound == constant.RelayFormatClaude && upstream == constant.RelayFormatOpenAI: + return relayconvert.ConverterClaudeMessagesToOpenAIChat + case inbound == constant.RelayFormatGemini && upstream == constant.RelayFormatOpenAI: + return relayconvert.ConverterGeminiContentToOpenAIChat + case inbound == constant.RelayFormatResponses && upstream == constant.RelayFormatOpenAI: + return relayconvert.ConverterOpenAIResponsesToOpenAIChat + + // 跨原生方向(请求侧:Claude→Gemini 为直连,其余为经 OpenAI 中枢的步骤链) + case inbound == constant.RelayFormatClaude && upstream == constant.RelayFormatGemini: + return relayconvert.ConverterClaudeMessagesToGeminiContent + case inbound == constant.RelayFormatGemini && upstream == constant.RelayFormatClaude: + return relayconvert.ConverterGeminiContentToClaudeMessages + case inbound == constant.RelayFormatResponses && upstream == constant.RelayFormatClaude: + return relayconvert.ConverterResponsesToClaudeMessages + case inbound == constant.RelayFormatResponses && upstream == constant.RelayFormatGemini: + return relayconvert.ConverterOpenAIResponsesToGemini + + default: + return "" + } +} + +// ResponseConverterIDForRoute 按 (上游格式, 客户端格式) 返回配对转换器 ID。 +// 响应侧与请求侧共用同一配对 ID(配对的 From=客户端入站格式、To=上游格式), +// 因此直接以 (client, upstream) 委托请求侧矩阵;RelayMode 传 chat(响应侧无模式歧义)。 +func ResponseConverterIDForRoute(upstream, client constant.RelayFormat) string { + return RequestConverterIDForRoute(client, upstream, int(constant.RelayModeChatCompletions)) +} diff --git a/relay/relaykit_bridge/stream.go b/relay/relaykit_bridge/stream.go index bb21bdf..4831a3c 100644 --- a/relay/relaykit_bridge/stream.go +++ b/relay/relaykit_bridge/stream.go @@ -11,10 +11,10 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/qianfree/team-api/internal/logic/monitor" "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/dto" "github.com/qianfree/team-api/relay/helper" "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/types" ) // relaykit 流式响应转换器接入桥接层。 @@ -30,33 +30,47 @@ import ( // TryConvertStreamViaRelaykit 尝试用 relaykit 流式转换器将上游 SSE 流转换为客户端格式。 // -// 返回: -// - usage:从最后一个带 Usage 的 chunk 提取的用量(无则零值 Usage) -// - ok:true 表示已接管响应(成功或优雅失败),调用方应直接返回,不再走旧路径; -// false 表示未接管(无 ChannelMeta / 同格式 / 无匹配转换器),调用方回退旧路径。 +// 返回值语义(三态): +// - ok=false, err=nil:未接管(nil info / 无 ChannelMeta / 同格式 / 无匹配转换器), +// 在任何 I/O 之前返回,调用方按自身策略回退或 hard-fail; +// - ok=true, err=nil:转换成功,流已正常收尾; +// - ok=true, err!=nil:已接管且 SSE 已写出,但流以错误结束。err 已带 ResponseWritten +// 语义(HTTP 头与 200 状态码既已提交,上层不得重写响应体、也不得换渠道重试), +// 调用方应原样 `return usage, err` 让 relay_handler 按「已送达 + 失败」记账与上报调度。 // -// 注意:未接管时在任何 I/O 之前即返回 false,旧路径行为零变化。 -func TryConvertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody io.Reader, writer http.ResponseWriter) (*common.Usage, bool) { +// usage 恒为从最后一个带 Usage 的 chunk 提取的用量(客户端断开时已叠加中断兜底估算)。 +func TryConvertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody io.Reader, writer http.ResponseWriter) (*common.Usage, bool, error) { if info == nil || info.ChannelMeta == nil { - return nil, false + return nil, false, nil } return convertStreamViaRelaykit(ctx, info, upstreamBody, writer) } // convertStreamViaRelaykit 流式转换核心:不读取特性开关配置(由公开入口保证), // 抽离出来便于单测(参照 internal/logic/relay 中 isChannelInProviders 的纯函数抽离手法)。 -func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody io.Reader, writer http.ResponseWriter) (*common.Usage, bool) { - upstream := helper.ProviderNativeFormat(info.ChannelMeta.ChannelType) +func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstreamBody io.Reader, writer http.ResponseWriter) (*common.Usage, bool, error) { + upstream := EffectiveUpstreamFormat(info) clientFormat := info.GetOriginalClientFormat() if upstream == clientFormat { - return nil, false // 同格式无需转换 + return nil, false, nil // 同格式无需转换 } - fn, converterID, ok := relayconvert.LookupStreamConverter(types.RelayFormat(upstream), types.RelayFormat(clientFormat)) + fn, converterID, ok := relayconvert.LookupStreamConverter(KitFormat(upstream), KitFormat(clientFormat)) if !ok { - return nil, false // 无匹配的 relaykit 流式转换器 + return nil, false, nil // 无匹配的 relaykit 流式转换器 } + // 收尾语义按客户端格式区分(与各方向旧实现一致): + // - OpenAI 客户端:SSE 流以 data: [DONE] 结束,另需保证存在带 + // finish_reason 的终止 chunk(转换器未产出时由本层补发); + // - Gemini 客户端:真实 Gemini API 无 [DONE] 哨兵、流自然结束即收尾; + // 官方 SDK(@google/genai)对每个 data 帧做 JSON.parse,写 [DONE] 会直接抛 + // SyntaxError 导致客户端报错(gemini 原生透传路径也从不写 [DONE],口径一致); + // - Claude / Responses 客户端:终止语义由事件本身承载(message_stop / + // response.completed),不写 [DONE],补帧也无意义。 + needsDone := clientFormat == constant.RelayFormatOpenAI + needsTerminalChunk := clientFormat == constant.RelayFormatOpenAI + // 设置 SSE 头 + 并发安全 writer + 保活 ping(与旧 handleStreamToOpenAI 一致) helper.SetEventStreamHeaders(writer) safeWriter := helper.NewSafeWriter(writer) @@ -67,19 +81,17 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr // 置 CacheIncludedInPrompt 让计费按明细扣减缓存部分,避免「input 全价 + cache 价」双重计费 capturedUsage := &common.Usage{CacheIncludedInPrompt: true} var ( - gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk - firstChunk bool - writeFailed bool // 写客户端已失败(连接不可达),客户端断开的可靠信号 - transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 + gotFinish bool // 转换器是否已产出带 finish_reason 的结束 chunk + gotResponseTerminal bool // Responses 客户端方向:转换器是否已产出终止态事件(completed/failed) + firstChunk bool + writeFailed bool // 写客户端已失败(连接不可达),客户端断开的可靠信号 + transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 ) - // chunkWriter:将转换器产出的 *dto.ChatCompletionStreamResponse 序列化为 SSE 写出, - // 并提取 Usage / 记录首字节时间 / 追踪是否已发结束 chunk。 - chunkWriter := func(chunk any) error { - streamChunk, ok := chunk.(*dto.ChatCompletionStreamResponse) - if !ok { - return nil // 忽略非预期类型 - } + // writeChatChunk:OpenAI chat chunk 的写出与状态追踪(Usage 提取、finish 追踪、 + // 中断估算文本长度)。池化缓冲 + 绑定 encoder:序列化与拼帧零分配,整帧单次写出 + //(帧原子,ping 插不进去)。 + writeChatChunk := func(streamChunk *dto.ChatCompletionStreamResponse) error { if !firstChunk { firstChunk = true info.SetFirstResponseTime() @@ -103,7 +115,6 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr transferredTextLen += len(*choice.Delta.ReasoningContent) } } - // 池化缓冲 + 绑定 encoder:序列化与拼帧零分配,整帧单次写出(帧原子,ping 插不进去) if err := helper.WriteSSEDataJSON(safeWriter, streamChunk); err != nil { // 序列化失败与写客户端失败必须分开:前者是转换器产出了不可序列化的 chunk, // 后者才说明客户端已不可达(writeFailed 驱动上层按流中断结算、不再补写 [DONE]) @@ -115,6 +126,46 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr return nil } + // chunkWriter:按 chunk 类型分派写出。 + // - *dto.ChatCompletionStreamResponse:OpenAI 客户端方向,序列化为纯 data 帧; + // - *relayconvert.StreamEvent:非 OpenAI 客户端方向的通用封装,Event 非空写事件帧 + // (Claude/Responses 风格),为空写纯 data 帧(Gemini 风格),Usage 非空即捕获。 + chunkWriter := func(chunk any) error { + switch c := chunk.(type) { + case *dto.ChatCompletionStreamResponse: + return writeChatChunk(c) + case *relayconvert.StreamEvent: + if c == nil || c.Data == nil { + return nil + } + if !firstChunk { + firstChunk = true + info.SetFirstResponseTime() + } + if c.Usage != nil { + captureDtoUsage(capturedUsage, c.Usage) + } + if c.Event == "response.failed" || c.Event == "response.completed" { + gotResponseTerminal = true + } + var err error + if c.Event != "" { + err = helper.WriteSSEEventJSON(safeWriter, c.Event, c.Data) + } else { + err = helper.WriteSSEDataJSON(safeWriter, c.Data) + } + if err != nil { + if !errors.Is(err, helper.ErrSSEPayloadMarshal) { + writeFailed = true + } + return err + } + return nil + default: + return nil // 忽略非预期类型 + } + } + // writeTerminal 在转换器未产出结束 chunk 时补发一个终止 chunk,保证客户端正常收尾。 writeTerminal := func() { stop := "stop" @@ -152,21 +203,73 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr setEndReason(common.StreamEndReasonClientGone, endErr) // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 helper.ApplyInterruptedUsageFallback(info, capturedUsage, transferredTextLen) - return capturedUsage, true + return capturedUsage, true, common.ErrStreamInterrupted } g.Log().Warningf(ctx, "[relaykit] convert stream failed (converter=%s): %v", converterID, err) - if !gotFinish { + if needsTerminalChunk && !gotFinish { writeTerminal() } - _ = helper.WriteSSEData(safeWriter, "[DONE]") + if needsDone { + _ = helper.WriteSSEData(safeWriter, "[DONE]") + } + // Responses 客户端:协议要求每个 response 以终止态事件收尾(completed/failed)。 + // 转换器以错误退出且未产出任何终止态事件时必须补发 failed,否则 200 SSE 静默 + // 结束、客户端(codex 等)会一直等待终止态而挂起;转换器已自行收尾的 + //(如 Gemini 安全拦截发 completed 拒答)不得追加第二终止事件。 + if clientFormat == constant.RelayFormatResponses && !gotResponseTerminal { + _ = helper.WriteSSEEventJSON(safeWriter, "response.failed", map[string]any{ + "type": "response.failed", + "response": map[string]any{ + "id": fmt.Sprintf("resp_%s", info.RequestID), + "object": "response", + "status": "failed", + "error": map[string]any{ + "code": "upstream_error", + "message": err.Error(), + }, + }, + }) + } setEndReason(common.StreamEndReasonError, err) - return capturedUsage, true + return capturedUsage, true, streamConvertError(err) } - if !gotFinish { + if needsTerminalChunk && !gotFinish { writeTerminal() } - _ = helper.WriteSSEData(safeWriter, "[DONE]") + if needsDone { + _ = helper.WriteSSEData(safeWriter, "[DONE]") + } setEndReason(common.StreamEndReasonDone, nil) - return capturedUsage, true + return capturedUsage, true, nil +} + +// streamConvertError 把转换器在流中途返回的业务错误映射为宿主错误类型。 +// +// 一律置 ResponseWritten:走到这里 SetEventStreamHeaders 早已提交 200 与 SSE 头, +// 上层既不能改状态码、也不能换渠道重试(客户端已收到部分事件流)。 +// +// 分类依据是错误性质而非状态码方便性: +// - 内容安全拦截(ErrContentBlocked)是客户端提示词问题,映射为请求类错误, +// 避免正常渠道因用户违规内容被扣健康分/熔断; +// - 其余(上游 error 事件、上游流畸形等)是上游故障,按 502 上游错误上报调度 FSM。 +func streamConvertError(err error) error { + if errors.Is(err, relayconvert.ErrContentBlocked) { + reqErr := constant.NewRequestError(err.Error(), err) + reqErr.ResponseWritten = true + return reqErr + } + upErr := constant.NewUpstreamError(http.StatusBadGateway, err.Error(), err) + upErr.ResponseWritten = true + return upErr +} + +// captureDtoUsage 把 StreamEvent 携带的带明细 Usage 累计进宿主 Usage(后到覆盖先到)。 +func captureDtoUsage(dst *common.Usage, u *dto.UsageWithDetails) { + dst.PromptTokens = u.PromptTokens + dst.CompletionTokens = u.CompletionTokens + dst.TotalTokens = u.TotalTokens + dst.CacheCreationTokens = convertedCacheCreationTokens(u.PromptTokensDetails) + dst.PromptTokensDetails = common.DtoTokenDetailsToCommon(u.PromptTokensDetails) + dst.CompletionTokenDetails = common.DtoTokenDetailsToCommon(u.CompletionTokenDetails) } diff --git a/relay/relaykit_bridge/stream_test.go b/relay/relaykit_bridge/stream_test.go index c5f1ced..dff4c36 100644 --- a/relay/relaykit_bridge/stream_test.go +++ b/relay/relaykit_bridge/stream_test.go @@ -51,10 +51,13 @@ data: {"type":"message_stop"} info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI) rec := httptest.NewRecorder() - usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), rec) + usage, ok, err := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), rec) if !ok { t.Fatal("expected ok=true (handled), got false") } + if err != nil { + t.Fatalf("expected nil err on clean stream, got %v", err) + } if usage == nil { t.Fatal("expected non-nil usage") } @@ -108,10 +111,13 @@ data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"prompt info := newStreamTestRelayInfo(constant.ProviderGemini, constant.RelayFormatOpenAI) rec := httptest.NewRecorder() - usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(geminiStream), rec) + usage, ok, err := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(geminiStream), rec) if !ok { t.Fatal("expected ok=true (handled), got false") } + if err != nil { + t.Fatalf("expected nil err on clean stream, got %v", err) + } if usage == nil { t.Fatal("expected non-nil usage") } @@ -143,7 +149,10 @@ func TestConvertStreamViaRelaykit_SameFormatFallback(t *testing.T) { info := newStreamTestRelayInfo(constant.ProviderOpenAI, constant.RelayFormatOpenAI) rec := httptest.NewRecorder() - usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec) + usage, ok, err := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec) + if err != nil { + t.Fatalf("expected nil err on fallback, got %v", err) + } if ok { t.Fatal("expected ok=false for same format, got true") } @@ -155,12 +164,16 @@ func TestConvertStreamViaRelaykit_SameFormatFallback(t *testing.T) { } } -// TestConvertStreamViaRelaykit_NoMatchingRoute 无匹配流式转换器的方向(OpenAI→Gemini)应回退。 +// TestConvertStreamViaRelaykit_NoMatchingRoute 无匹配流式转换器的方向应回退。 +// Ollama 上游 → Claude 客户端未注册(Ollama 仅覆盖 OpenAI 客户端方向)。 func TestConvertStreamViaRelaykit_NoMatchingRoute(t *testing.T) { - info := newStreamTestRelayInfo(constant.ProviderOpenAI, constant.RelayFormatGemini) + info := newStreamTestRelayInfo(constant.ProviderOllama, constant.RelayFormatClaude) rec := httptest.NewRecorder() - usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec) + usage, ok, err := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec) + if err != nil { + t.Fatalf("expected nil err on fallback, got %v", err) + } if ok { t.Fatal("expected ok=false for unmatched route, got true") } @@ -176,13 +189,13 @@ func TestConvertStreamViaRelaykit_NoMatchingRoute(t *testing.T) { // (nil 守卫位于公开入口;core convertStreamViaRelaykit 由调用方保证 info 非空。) func TestTryConvertStreamViaRelaykit_NilGuards(t *testing.T) { rec := httptest.NewRecorder() - if _, ok := TryConvertStreamViaRelaykit(context.Background(), nil, strings.NewReader(""), rec); ok { + if _, ok, _ := TryConvertStreamViaRelaykit(context.Background(), nil, strings.NewReader(""), rec); ok { t.Fatal("expected ok=false for nil info") } info := newStreamTestRelayInfo(constant.ProviderClaude, constant.RelayFormatOpenAI) info.ChannelMeta = nil - if _, ok := TryConvertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec); ok { + if _, ok, _ := TryConvertStreamViaRelaykit(context.Background(), info, strings.NewReader(""), rec); ok { t.Fatal("expected ok=false for nil ChannelMeta") } } @@ -238,13 +251,17 @@ data: {"type":"message_stop"} // ctx 用 Background(永不取消),精确模拟「写失败先于 ctx 取消被观察到」的竞态 w := &failOnSubstringWriter{rec: httptest.NewRecorder(), marker: "how can I help you"} - usage, ok := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), w) + usage, ok, err := convertStreamViaRelaykit(context.Background(), info, strings.NewReader(claudeStream), w) if !ok { t.Fatal("expected ok=true (handled), got false") } if usage == nil { t.Fatal("expected non-nil usage") } + // 中断信号必须由桥接层直接透出:调用方据此按流中断结算,不再自行读 StreamStatus 判定 + if !errors.Is(err, common.ErrStreamInterrupted) { + t.Fatalf("err = %v, want common.ErrStreamInterrupted", err) + } if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonClientGone { t.Fatalf("end reason = %q, want %q (client write failure misclassified)", reason, common.StreamEndReasonClientGone) } diff --git a/relaykit/dispatch/types.go b/relaykit/dispatch/types.go index a9f769f..286289e 100644 --- a/relaykit/dispatch/types.go +++ b/relaykit/dispatch/types.go @@ -160,6 +160,7 @@ type Outcome struct { Class ErrorClass LatencyMs float64 // Probe 标记本结果来自探测请求(管理后台渠道测试 / 自动探测 cron)而非真实流量。 - // 纯核心库不消费,仅供适配层观测日志区分健康分变化来源。 + // 纯核心库不消费;适配层据此跳过探测失败对健康 EWMA 的衰减(避免持续失败的探测把 + // 无真实流量渠道的健康分指数级拖垮),探测成功仍照常记录,熔断窗口计数不受影响。 Probe bool } diff --git a/relaykit/dto/claude.go b/relaykit/dto/claude.go index 36c1c3a..f497f35 100644 --- a/relaykit/dto/claude.go +++ b/relaykit/dto/claude.go @@ -70,6 +70,13 @@ type ClaudeTool struct { InputSchema any `json:"input_schema,omitempty"` Type string `json:"type,omitempty"` // "custom"(默认)或内置类型 CacheControl *ClaudeCacheControl `json:"cache_control,omitempty"` // Prompt Caching 断点 + + // 服务端内置工具(web_search_* 等)的配置字段。重序列化(如模型名改写)时必须保留, + // 否则搜索限制配置会静默丢失(wire 保真测试 dto/wire_fidelity_test.go 守护)。 + MaxUses *int `json:"max_uses,omitempty"` + AllowedDomains []string `json:"allowed_domains,omitempty"` + BlockedDomains []string `json:"blocked_domains,omitempty"` + UserLocation any `json:"user_location,omitempty"` } // ClaudeToolChoice Claude 工具选择 diff --git a/relaykit/dto/coze.go b/relaykit/dto/coze.go deleted file mode 100644 index df8c637..0000000 --- a/relaykit/dto/coze.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package dto — Coze(字节扣子)v3 协议数据结构。 -// -// 从 relay/channel/coze 提取的纯数据结构,供 relaykit 转换器与宿主桥接层使用。 -// 不依赖任何宿主类型(RelayInfo / GoFrame),仅含协议字段。 -package dto - -// CozeCreateRequest Coze v3 创建对话请求。 -// BotID 取自渠道模型映射后的上游模型名;Query 取最后一条 user 消息文本。 -type CozeCreateRequest struct { - ConversationID string `json:"conversation_id,omitempty"` - BotID string `json:"bot_id"` - User string `json:"user"` - Query string `json:"query"` - Stream bool `json:"stream"` -} - -// CozeMessage Coze 流式 SSE 事件中的消息载荷。 -// -// Coze SSE 帧形如: -// -// event: conversation.message.delta -// data: {"role":"assistant","type":"answer","content":"Hello"} -// -// 仅 type=="answer" 的消息是模型正文输出,其余类型(follow_up/voice 等)在转换时忽略。 -type CozeMessage struct { - Role string `json:"role"` - Type string `json:"type"` - Content string `json:"content"` - ContentType string `json:"content_type"` -} diff --git a/relaykit/dto/dify.go b/relaykit/dto/dify.go deleted file mode 100644 index 38bcc4d..0000000 --- a/relaykit/dto/dify.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package dto — Dify chat-messages 协议数据结构。 -// -// 从 relay/channel/dify 提取的纯数据结构,供 relaykit 转换器与宿主桥接层使用。 -// 不依赖任何宿主类型(RelayInfo / GoFrame),仅含协议字段。 -package dto - -// DifyRequest Dify Chat Messages 请求体。 -// 所有 OpenAI 消息被拼接为单个 Query 字符串;ResponseMode 由是否流式决定。 -type DifyRequest struct { - Inputs map[string]any `json:"inputs"` - Query string `json:"query"` - ResponseMode string `json:"response_mode"` // "blocking" | "streaming" - User string `json:"user"` -} - -// DifyUsage Dify 响应中的 token 用量。 -type DifyUsage struct { - TotalTokens int `json:"total_tokens"` - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` -} - -// DifyBlockingResponse Dify 非流式(blocking)响应。 -type DifyBlockingResponse struct { - Answer string `json:"answer"` - Metadata DifyMeta `json:"metadata"` - ConversationID string `json:"conversation_id"` - MessageID string `json:"message_id"` -} - -// DifyMeta Dify 响应 metadata,承载用量信息。 -type DifyMeta struct { - Usage DifyUsage `json:"usage"` -} - -// DifyStreamEvent Dify 流式 SSE 事件数据(每条 data 行的载荷)。 -// -// 事件类型(Event 字段): -// - "message":正文增量(Answer 字段为本次增量文本) -// - "message_end":流结束,Metadata.Usage 携带用量 -// - "error":错误 -type DifyStreamEvent struct { - Event string `json:"event"` - Answer string `json:"answer"` - ConversationID string `json:"conversation_id"` - MessageID string `json:"message_id"` - Metadata DifyMeta `json:"metadata"` -} diff --git a/relaykit/dto/gemini.go b/relaykit/dto/gemini.go index 6e29298..32eeec0 100644 --- a/relaykit/dto/gemini.go +++ b/relaykit/dto/gemini.go @@ -19,8 +19,11 @@ type GeminiChatRequest struct { // GeminiContent Gemini 内容(角色 + 部分) type GeminiContent struct { - Role string `json:"role"` - Parts []GeminiPart `json:"parts"` + Role string `json:"role"` + // Parts 必须 omitempty:nil 序列化为 "parts":null 会使 Gemini 官方 SDK/CLI 崩溃 + //(isValidContent 只判 undefined 后直接读 .length);真实 Gemini API 的收尾 chunk + // 形态是 {"role":"model"} 不带 parts 键,SDK 对该形态有显式容忍分支 + Parts []GeminiPart `json:"parts,omitempty"` } // GeminiPart Gemini 内容部分 @@ -115,7 +118,7 @@ type GeminiImageConfig struct { // GeminiThinkingConfig Gemini 思考配置 type GeminiThinkingConfig struct { IncludeThoughts bool `json:"includeThoughts"` - ThoughtBudget *int `json:"thoughtBudget,omitempty"` + ThinkingBudget *int `json:"thinkingBudget,omitempty"` ThinkingLevel string `json:"thinkingLevel,omitempty"` } diff --git a/relaykit/dto/ollama.go b/relaykit/dto/ollama.go index a655409..d2f9df1 100644 --- a/relaykit/dto/ollama.go +++ b/relaykit/dto/ollama.go @@ -35,14 +35,17 @@ type OllamaMessage struct { // 流式(NDJSON)每行一个本结构;非流式为单个本结构。 // 最后一条(Done==true)携带 PromptEvalCount / EvalCount 用量与 DoneReason 结束原因。 type OllamaChatResponse struct { - Model string `json:"model"` - CreatedAt string `json:"created_at"` - Message OllamaMessage `json:"message"` - Done bool `json:"done"` - DoneReason string `json:"done_reason,omitempty"` - TotalDuration int64 `json:"total_duration,omitempty"` - PromptEvalCount int `json:"prompt_eval_count,omitempty"` - EvalCount int `json:"eval_count,omitempty"` + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Message OllamaMessage `json:"message"` + Done bool `json:"done"` + DoneReason string `json:"done_reason,omitempty"` + TotalDuration int64 `json:"total_duration,omitempty"` + LoadDuration int64 `json:"load_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + PromptEvalDuration int64 `json:"prompt_eval_duration,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + EvalDuration int64 `json:"eval_duration,omitempty"` } // OllamaTool Ollama 工具定义(OpenAI function 格式一致)。 diff --git a/relaykit/dto/openai_responses.go b/relaykit/dto/openai_responses.go new file mode 100644 index 0000000..8beedcc --- /dev/null +++ b/relaykit/dto/openai_responses.go @@ -0,0 +1,294 @@ +package dto + +import "encoding/json" + +// ========== Responses API 请求 ========== + +// OpenAIResponsesRequest OpenAI Responses API 请求 +// https://platform.openai.com/docs/api-reference/responses/create +type OpenAIResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Include json.RawMessage `json:"include,omitempty"` + Instructions json.RawMessage `json:"instructions,omitempty"` + MaxOutputTokens *uint `json:"max_output_tokens,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + Stream *bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Logprobs *int `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Truncation json.RawMessage `json:"truncation,omitempty"` + User json.RawMessage `json:"user,omitempty"` + MaxToolCalls *uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Conversation json.RawMessage `json:"conversation,omitempty"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + Background *bool `json:"background,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + FrequencyPenalty json.RawMessage `json:"frequency_penalty,omitempty"` + PresencePenalty json.RawMessage `json:"presence_penalty,omitempty"` +} + +// Reasoning 推理参数(用于 o1/o3 等推理模型) +type Reasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ========== Responses API 响应 ========== + +// OpenAIResponsesResponse Responses API 非流式响应 +type OpenAIResponsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int `json:"created_at"` + CompletedAt int `json:"completed_at,omitempty"` + Status json.RawMessage `json:"status"` + Error any `json:"error"` + IncompleteDetails any `json:"incomplete_details"` + Instructions any `json:"instructions"` + MaxOutputTokens *int `json:"max_output_tokens"` + MaxToolCalls *int `json:"max_tool_calls,omitempty"` + Model string `json:"model"` + Output []ResponsesOutput `json:"output"` + OutputText string `json:"output_text,omitempty"` + ParallelToolCalls bool `json:"parallel_tool_calls"` + PreviousResponseID any `json:"previous_response_id"` + Prompt any `json:"prompt"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PromptCacheRetention string `json:"prompt_cache_retention,omitempty"` + Reasoning *ResponsesReasoning `json:"reasoning"` + SafetyIdentifier string `json:"safety_identifier,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Store bool `json:"store"` + Background *bool `json:"background,omitempty"` + Conversation any `json:"conversation"` + Temperature *float64 `json:"temperature"` + Text *ResponsesText `json:"text,omitempty"` + ToolChoice any `json:"tool_choice"` + Tools []any `json:"tools"` + TopP *float64 `json:"top_p"` + Truncation any `json:"truncation"` + Usage *ResponsesUsage `json:"usage,omitempty"` + User any `json:"user"` + Metadata any `json:"metadata"` +} + +// IncompleteDetails 不完整响应详情 +type IncompleteDetails struct { + Reason string `json:"reason,omitempty"` +} + +// ResponsesUsage Responses API 使用量(字段名与 Chat Completions 不同) +type ResponsesUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + OutputTokenDetails *OutputTokenDetails `json:"output_tokens_details"` +} + +// ResponsesReasoning 推理配置(响应中的 reasoning 字段) +type ResponsesReasoning struct { + Effort any `json:"effort"` + Summary any `json:"summary"` +} + +// ResponsesText 文本配置(响应中的 text 字段) +type ResponsesText struct { + Format ResponsesTextFormat `json:"format"` +} + +// ResponsesTextFormat 文本格式 +type ResponsesTextFormat struct { + Type string `json:"type"` +} + +// InputTokenDetails 输入 token 细分 +type InputTokenDetails struct { + CachedTokens int `json:"cached_tokens"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` // 本次写入缓存的 token(input_tokens 子集,按普通输入价计费) + TextTokens int `json:"text_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + ImageTokens int `json:"image_tokens,omitempty"` +} + +// OutputTokenDetails 输出 token 细分 +type OutputTokenDetails struct { + TextTokens int `json:"text_tokens,omitempty"` + AudioTokens int `json:"audio_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"` + RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"` +} + +// ResponsesOutput 响应输出项(通用结构,支持所有 output item 类型) +// 具体 type 包括:message、function_call、web_search_call、file_search_call、 +// computer_call、reasoning、image_generation_call、code_interpreter_call、 +// shell_call、local_shell_call、apply_patch_call、mcp_call、mcp_list_tools、 +// mcp_approval_request、custom_tool_call、compaction +type ResponsesOutput struct { + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status,omitempty"` + Role string `json:"role,omitempty"` + Content []ResponsesOutputContent `json:"content,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + // action 字段为多态:web_search_call / shell_call / local_shell_call / apply_patch_call + // 各类型的 action 结构不同(见 ResponsesWebSearchAction / ResponsesShellAction / + // ResponsesPatchAction)。此处用 RawMessage 原样透传,避免多个字段共用同一 json tag + // 导致 encoding/json 在序列化和反序列化时静默丢弃全部 action 字段。 + Action json.RawMessage `json:"action,omitempty"` + // file_search_call + Queries []string `json:"queries,omitempty"` + Results []ResponsesFileSearchResult `json:"results,omitempty"` + // computer_call + PendingSafetyChecks []ResponsesSafetyCheck `json:"pending_safety_checks,omitempty"` + // reasoning + Summary []ResponsesSummaryPart `json:"summary,omitempty"` + EncryptedContent string `json:"encrypted_content,omitempty"` + // image_generation_call + Result string `json:"result,omitempty"` + // code_interpreter_call + Code string `json:"code,omitempty"` + ContainerID string `json:"container_id,omitempty"` + Outputs []ResponsesCodeInterpreterOutput `json:"outputs,omitempty"` + // mcp_call + ServerLabel string `json:"server_label,omitempty"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` + ApprovalRequestID string `json:"approval_request_id,omitempty"` + // mcp_list_tools + Tools []ResponsesMCPTool `json:"tools,omitempty"` + // mcp_approval_request + // custom_tool_call + Input string `json:"input,omitempty"` +} + +// ResponsesOutputContent 响应输出内容块 +type ResponsesOutputContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Annotations []ResponsesAnnotation `json:"annotations,omitempty"` + // refusal 内容类型 + Refusal string `json:"refusal,omitempty"` + // reasoning_text 内容类型 +} + +// ResponsesAnnotation 注解类型(联合结构,支持所有注解子类型) +type ResponsesAnnotation struct { + Type string `json:"type"` + FileID string `json:"file_id,omitempty"` + Filename string `json:"filename,omitempty"` + Index int `json:"index,omitempty"` + // url_citation + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + StartIndex int `json:"start_index,omitempty"` + EndIndex int `json:"end_index,omitempty"` +} + +// ResponsesWebSearchAction Web 搜索动作 +type ResponsesWebSearchAction struct { + Type string `json:"type"` + Query string `json:"query,omitempty"` + URL string `json:"url,omitempty"` + Pattern string `json:"pattern,omitempty"` + Sources []ResponsesWebSearchSource `json:"sources,omitempty"` +} + +// ResponsesWebSearchSource Web 搜索来源 +type ResponsesWebSearchSource struct { + Type string `json:"type"` + URL string `json:"url,omitempty"` +} + +// ResponsesFileSearchResult 文件搜索结果项 +type ResponsesFileSearchResult struct { + FileID string `json:"file_id"` + Text string `json:"text"` + Score float64 `json:"score"` +} + +// ResponsesSafetyCheck 安全检查项 +type ResponsesSafetyCheck struct { + ID string `json:"id"` + Code string `json:"code"` + Message string `json:"message"` +} + +// ResponsesCodeInterpreterOutput 代码解释器输出 +type ResponsesCodeInterpreterOutput struct { + Type string `json:"type"` + Logs string `json:"logs,omitempty"` + URL string `json:"url,omitempty"` +} + +// ResponsesShellAction Shell 命令动作 +type ResponsesShellAction struct { + Type string `json:"type"` + Command []string `json:"command,omitempty"` + MaxOutputLength *int `json:"max_output_length,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Env map[string]string `json:"env,omitempty"` + User string `json:"user,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` +} + +// ResponsesPatchAction 补丁动作 +type ResponsesPatchAction struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + Patch string `json:"patch,omitempty"` +} + +// ResponsesMCPTool MCP 工具描述 +type ResponsesMCPTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema any `json:"inputSchema,omitempty"` + Annotations any `json:"annotations,omitempty"` +} + +// ========== Responses API 流式响应 ========== + +// ResponsesStreamResponse 流式事件包装 +type ResponsesStreamResponse struct { + Type string `json:"type"` + Response *OpenAIResponsesResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Item *ResponsesOutput `json:"item,omitempty"` + OutputIndex *int `json:"output_index,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + SummaryIndex *int `json:"summary_index,omitempty"` + ItemID string `json:"item_id,omitempty"` + Part *ResponsesSummaryPart `json:"part,omitempty"` +} + +// ResponsesSummaryPart 推理摘要部分 +type ResponsesSummaryPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// HasError 检查响应是否包含错误 +func (r *OpenAIResponsesResponse) HasError() bool { + if r.Error == nil { + return false + } + return true +} diff --git a/relaykit/dto/testdata/wire/claude_request_full.json b/relaykit/dto/testdata/wire/claude_request_full.json new file mode 100644 index 0000000..4b77e87 --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_request_full.json @@ -0,0 +1,34 @@ +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 2048, + "system": [ + {"type": "text", "text": "You are a research assistant.", "cache_control": {"type": "ephemeral"}} + ], + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "Describe this image and check the weather in Boston."}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "The user wants weather info for Boston.", "signature": "EuYBCkQYAiJAexample"}, + {"type": "text", "text": "I'll check the weather."}, + {"type": "tool_use", "id": "toolu_01A09q90qw90lq917835lq9", "name": "get_weather", "input": {"location": "Boston, MA"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01A09q90qw90lq917835lq9", "content": "68F, sunny", "is_error": false} + ]} + ], + "tools": [ + {"name": "get_weather", "description": "Get the current weather in a given location", "input_schema": {"type": "object", "properties": {"location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"}}, "required": ["location"]}, "cache_control": {"type": "ephemeral"}}, + {"type": "web_search_20250305", "name": "web_search", "max_uses": 5, "allowed_domains": ["example.com"]} + ], + "tool_choice": {"type": "auto", "disable_parallel_tool_use": false}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "temperature": 1, + "top_p": 0.95, + "top_k": 40, + "stop_sequences": ["\n\nHuman:"], + "stream": true, + "metadata": {"user_id": "user-1234"}, + "service_tier": "auto" +} diff --git a/relaykit/dto/testdata/wire/claude_response_full.json b/relaykit/dto/testdata/wire/claude_response_full.json new file mode 100644 index 0000000..3e8adca --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_response_full.json @@ -0,0 +1,22 @@ +{ + "id": "msg_01XFDUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [ + {"type": "thinking", "thinking": "Let me analyze the request.", "signature": "EuYBCkQYAiJAexample"}, + {"type": "text", "text": "The weather in Boston is 68F and sunny."}, + {"type": "tool_use", "id": "toolu_01T1x1fJ34qAmk2tNTrN7Up6", "name": "get_weather", "input": {"location": "Boston, MA"}} + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 472, + "output_tokens": 91, + "cache_creation_input_tokens": 130, + "cache_read_input_tokens": 50, + "cache_creation": {"ephemeral_5m_input_tokens": 100, "ephemeral_1h_input_tokens": 30}, + "server_tool_use": {"web_search_requests": 2}, + "service_tier": "standard" + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_content_block_delta.json b/relaykit/dto/testdata/wire/claude_stream_event_content_block_delta.json new file mode 100644 index 0000000..51186a4 --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_content_block_delta.json @@ -0,0 +1,8 @@ +{ + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": "{\"city\":\"Shanghai\"}" + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_content_block_start.json b/relaykit/dto/testdata/wire/claude_stream_event_content_block_start.json new file mode 100644 index 0000000..04833df --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_content_block_start.json @@ -0,0 +1,10 @@ +{ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_01T1x1fJ34qAmk2tNTrN7Up6", + "name": "get_weather", + "input": {} + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_message_delta.json b/relaykit/dto/testdata/wire/claude_stream_event_message_delta.json new file mode 100644 index 0000000..1aae8e7 --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_message_delta.json @@ -0,0 +1,10 @@ +{ + "type": "message_delta", + "delta": { + "stop_reason": "end_turn", + "stop_sequence": null + }, + "usage": { + "output_tokens": 42 + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_message_start.json b/relaykit/dto/testdata/wire/claude_stream_event_message_start.json new file mode 100644 index 0000000..b915d85 --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_message_start.json @@ -0,0 +1,18 @@ +{ + "type": "message_start", + "message": { + "id": "msg_014p7gG3wDgGV9EUtLvnow3U", + "type": "message", + "role": "assistant", + "content": [], + "model": "claude-sonnet-4-5", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 1, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 5 + } + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_signature_delta.json b/relaykit/dto/testdata/wire/claude_stream_event_signature_delta.json new file mode 100644 index 0000000..ea9846c --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_signature_delta.json @@ -0,0 +1,8 @@ +{ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "signature_delta", + "signature": "EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1pkiMOYds" + } +} diff --git a/relaykit/dto/testdata/wire/claude_stream_event_thinking_delta.json b/relaykit/dto/testdata/wire/claude_stream_event_thinking_delta.json new file mode 100644 index 0000000..b2bbb02 --- /dev/null +++ b/relaykit/dto/testdata/wire/claude_stream_event_thinking_delta.json @@ -0,0 +1,8 @@ +{ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "Let me think about this step by step..." + } +} diff --git a/relaykit/dto/testdata/wire/gemini_request_full.json b/relaykit/dto/testdata/wire/gemini_request_full.json new file mode 100644 index 0000000..be641de --- /dev/null +++ b/relaykit/dto/testdata/wire/gemini_request_full.json @@ -0,0 +1,38 @@ +{ + "systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]}, + "contents": [ + {"role": "user", "parts": [ + {"text": "What is in this image? Also check the weather in Boston."}, + {"inlineData": {"mimeType": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "model", "parts": [ + {"text": "I'll check the weather.", "thoughtSignature": "CrgBAXLI2nHzExample"}, + {"functionCall": {"name": "get_weather", "args": {"location": "Boston, MA"}}} + ]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "get_weather", "response": {"temperature": 68, "condition": "sunny"}}} + ]} + ], + "tools": [ + {"functionDeclarations": [{"name": "get_weather", "description": "Get the current weather in a given location", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}]}, + {"googleSearch": {}} + ], + "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}}, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"} + ], + "generationConfig": { + "temperature": 0.7, + "topP": 0.95, + "topK": 40, + "maxOutputTokens": 2048, + "stopSequences": ["END"], + "responseMimeType": "application/json", + "thinkingConfig": {"includeThoughts": true, "thinkingBudget": 1024}, + "seed": 42, + "presencePenalty": 0.1, + "frequencyPenalty": 0.2, + "candidateCount": 1 + }, + "cachedContent": "cachedContents/example123" +} diff --git a/relaykit/dto/testdata/wire/gemini_response_full.json b/relaykit/dto/testdata/wire/gemini_response_full.json new file mode 100644 index 0000000..450e012 --- /dev/null +++ b/relaykit/dto/testdata/wire/gemini_response_full.json @@ -0,0 +1,32 @@ +{ + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"text": "Thinking about the request.", "thought": true}, + {"text": "The weather in Boston is 68F.", "thoughtSignature": "CrgBAXLI2nHzExample"}, + {"functionCall": {"name": "get_weather", "args": {"location": "Boston, MA"}}} + ] + }, + "finishReason": "STOP", + "index": 0, + "groundingMetadata": { + "webSearchQueries": ["Boston weather today"], + "groundingChunks": [{"web": {"uri": "https://example.com/weather", "title": "Boston Weather"}}], + "groundingSupports": [{"segment": {"startIndex": 0, "endIndex": 30, "text": "The weather in Boston is 68F."}, "groundingChunkIndices": [0]}] + } + } + ], + "usageMetadata": { + "promptTokenCount": 264, + "candidatesTokenCount": 100, + "totalTokenCount": 1500, + "thoughtsTokenCount": 1136, + "cachedContentTokenCount": 50, + "toolUsePromptTokenCount": 10, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 264}] + }, + "modelVersion": "gemini-2.5-flash", + "responseId": "abc123def456" +} diff --git a/relaykit/dto/testdata/wire/gemini_stream_chunk_full.json b/relaykit/dto/testdata/wire/gemini_stream_chunk_full.json new file mode 100644 index 0000000..c57ca52 --- /dev/null +++ b/relaykit/dto/testdata/wire/gemini_stream_chunk_full.json @@ -0,0 +1,35 @@ +{ + "candidates": [ + { + "index": 0, + "content": { + "role": "model", + "parts": [ + { + "text": "Let me think...", + "thought": true, + "thoughtSignature": "CtgBAcu98J5kyB0V" + }, + { + "functionCall": { + "name": "get_weather", + "args": { + "city": "Shanghai" + } + } + } + ] + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 5, + "totalTokenCount": 15, + "cachedContentTokenCount": 2, + "thoughtsTokenCount": 3 + }, + "modelVersion": "gemini-2.5-pro", + "responseId": "abc123DEF" +} diff --git a/relaykit/dto/testdata/wire/ollama_chat_request_full.json b/relaykit/dto/testdata/wire/ollama_chat_request_full.json new file mode 100644 index 0000000..203694d --- /dev/null +++ b/relaykit/dto/testdata/wire/ollama_chat_request_full.json @@ -0,0 +1,16 @@ +{ + "model": "llama3.2", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is in this image and what's the weather in Boston?", "images": ["iVBORw0KGgoAAAANSUhEUg=="]}, + {"role": "assistant", "content": "", "tool_calls": [{"function": {"name": "get_weather", "arguments": {"location": "Boston, MA"}}}]}, + {"role": "tool", "content": "68F, sunny", "tool_name": "get_weather"} + ], + "tools": [ + {"type": "function", "function": {"name": "get_weather", "description": "Get the current weather in a given location", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}} + ], + "stream": false, + "format": "json", + "think": true, + "options": {"temperature": 0.7, "top_p": 0.9, "num_predict": 1024, "stop": ["END"], "seed": 42} +} diff --git a/relaykit/dto/testdata/wire/ollama_chat_response_full.json b/relaykit/dto/testdata/wire/ollama_chat_response_full.json new file mode 100644 index 0000000..224ff51 --- /dev/null +++ b/relaykit/dto/testdata/wire/ollama_chat_response_full.json @@ -0,0 +1,18 @@ +{ + "model": "llama3.2", + "created_at": "2024-07-22T20:33:28.123648Z", + "message": { + "role": "assistant", + "content": "The weather in Boston is 68F and sunny.", + "thinking": "The user asked about the weather.", + "tool_calls": [{"function": {"name": "get_weather", "arguments": {"location": "Boston, MA"}}}] + }, + "done": true, + "done_reason": "stop", + "total_duration": 5191566416, + "load_duration": 2154458, + "prompt_eval_count": 26, + "prompt_eval_duration": 383809000, + "eval_count": 298, + "eval_duration": 4799921000 +} diff --git a/relaykit/dto/testdata/wire/openai_chat_request_full.json b/relaykit/dto/testdata/wire/openai_chat_request_full.json new file mode 100644 index 0000000..a53b6c6 --- /dev/null +++ b/relaykit/dto/testdata/wire/openai_chat_request_full.json @@ -0,0 +1,34 @@ +{ + "model": "gpt-4o", + "messages": [ + {"role": "system", "content": "You are a helpful weather assistant."}, + {"role": "user", "content": "What's the weather like in Boston today?"}, + {"role": "assistant", "content": null, "tool_calls": [{"id": "call_6vJDX8ZoWLDIcnatqxwc", "type": "function", "function": {"name": "get_current_weather", "arguments": "{\"location\": \"Boston, MA\", \"unit\": \"fahrenheit\"}"}}]}, + {"role": "tool", "tool_call_id": "call_6vJDX8ZoWLDIcnatqxwc", "content": "{\"temperature\": 68, \"condition\": \"sunny\"}"}, + {"role": "user", "content": [ + {"type": "text", "text": "Thanks! And what is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==", "detail": "high"}} + ]} + ], + "tools": [ + {"type": "function", "function": {"name": "get_current_weather", "description": "Get the current weather in a given location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}}, "required": ["location"]}, "strict": true}}, + {"type": "function", "function": {"name": "get_forecast", "description": "Get the weather forecast for the coming days", "parameters": {"type": "object", "properties": {"location": {"type": "string"}, "days": {"type": "integer"}}, "required": ["location", "days"]}}} + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "temperature": 0.7, + "top_p": 0.9, + "max_tokens": 1024, + "stop": ["END"], + "stream": true, + "stream_options": {"include_usage": true}, + "response_format": {"type": "json_object"}, + "reasoning_effort": "medium", + "web_search_options": {"search_context_size": "medium"}, + "seed": 42, + "presence_penalty": 0.1, + "frequency_penalty": 0.2, + "logprobs": true, + "top_logprobs": 3, + "user": "user-1234" +} diff --git a/relaykit/dto/testdata/wire/openai_chat_response_full.json b/relaykit/dto/testdata/wire/openai_chat_response_full.json new file mode 100644 index 0000000..c00dd0b --- /dev/null +++ b/relaykit/dto/testdata/wire/openai_chat_response_full.json @@ -0,0 +1,28 @@ +{ + "id": "chatcmpl-B9MBs8CjcvOU2jLn4n570S5qMJKcT", + "object": "chat.completion", + "created": 1741569952, + "model": "gpt-4o-2024-08-06", + "system_fingerprint": "fp_50cad350e4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I'll check the weather for you.", + "tool_calls": [ + {"id": "call_6vJDX8ZoWLDIcnatqxwc", "type": "function", "function": {"name": "get_current_weather", "arguments": "{\"location\": \"Boston, MA\"}"}} + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 1117, + "completion_tokens": 46, + "total_tokens": 1163, + "prompt_tokens_details": {"cached_tokens": 1024}, + "completion_tokens_details": {"reasoning_tokens": 12} + }, + "service_tier": "default" +} diff --git a/relaykit/dto/testdata/wire/openai_chat_stream_chunk_full.json b/relaykit/dto/testdata/wire/openai_chat_stream_chunk_full.json new file mode 100644 index 0000000..e6c257f --- /dev/null +++ b/relaykit/dto/testdata/wire/openai_chat_stream_chunk_full.json @@ -0,0 +1,40 @@ +{ + "id": "chatcmpl-abc123", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "gpt-4o", + "system_fingerprint": "fp_44709d6fcb", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": "Hello", + "reasoning_content": "Let me think...", + "tool_calls": [ + { + "index": 0, + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\":\"Shanghai\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 7, + "total_tokens": 19, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "completion_tokens_details": { + "reasoning_tokens": 3 + } + } +} diff --git a/relaykit/dto/testdata/wire/responses_request_full.json b/relaykit/dto/testdata/wire/responses_request_full.json new file mode 100644 index 0000000..748c50d --- /dev/null +++ b/relaykit/dto/testdata/wire/responses_request_full.json @@ -0,0 +1,29 @@ +{ + "model": "gpt-5", + "instructions": "You are a coding assistant.", + "input": [ + {"role": "user", "content": [ + {"type": "input_text", "text": "What's the weather in Boston?"}, + {"type": "input_image", "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="} + ]}, + {"type": "function_call", "call_id": "call_123", "name": "get_weather", "arguments": "{\"location\": \"Boston, MA\"}"}, + {"type": "function_call_output", "call_id": "call_123", "output": "68F, sunny"} + ], + "tools": [ + {"type": "function", "name": "get_weather", "description": "Get the current weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}, "strict": true}, + {"type": "web_search"} + ], + "tool_choice": "auto", + "parallel_tool_calls": true, + "reasoning": {"effort": "medium", "summary": "auto"}, + "max_output_tokens": 2048, + "temperature": 1, + "top_p": 0.95, + "stream": true, + "store": false, + "truncation": "auto", + "metadata": {"session": "abc"}, + "service_tier": "auto", + "prompt_cache_key": "cache-key-1", + "safety_identifier": "user-1234" +} diff --git a/relaykit/dto/testdata/wire/responses_response_full.json b/relaykit/dto/testdata/wire/responses_response_full.json new file mode 100644 index 0000000..1e7918e --- /dev/null +++ b/relaykit/dto/testdata/wire/responses_response_full.json @@ -0,0 +1,38 @@ +{ + "id": "resp_67ccd2bed1ec8190b14f964abc054267", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": 2048, + "model": "gpt-5-2025-08-07", + "output": [ + {"type": "reasoning", "id": "rs_67ccd2bf17f0819081ff3bb2cf6508e6", "summary": [{"type": "summary_text", "text": "Checking the weather for Boston."}]}, + {"type": "web_search_call", "id": "ws_67ccd2bf17f08190", "status": "completed"}, + {"type": "function_call", "id": "fc_67ccd2bf17f08190", "call_id": "call_123", "name": "get_weather", "arguments": "{\"location\": \"Boston, MA\"}", "status": "completed"}, + {"type": "message", "id": "msg_67ccd2bf17f0819081ff3bb2cf6508e6", "status": "completed", "role": "assistant", "content": [ + {"type": "output_text", "text": "The weather in Boston is 68F.", "annotations": [{"type": "url_citation", "start_index": 4, "end_index": 29, "url": "https://example.com/weather", "title": "Boston Weather"}]} + ]} + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": {"effort": "medium", "summary": "auto"}, + "store": true, + "temperature": 1, + "text": {"format": {"type": "text"}}, + "tool_choice": "auto", + "tools": [{"type": "function", "name": "get_weather", "description": "Get the current weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}, "strict": true}], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 36, + "input_tokens_details": {"cached_tokens": 12}, + "output_tokens": 87, + "output_tokens_details": {"reasoning_tokens": 64}, + "total_tokens": 123 + }, + "user": null, + "metadata": {} +} diff --git a/relaykit/dto/testdata/wire/responses_stream_event_output_item_added.json b/relaykit/dto/testdata/wire/responses_stream_event_output_item_added.json new file mode 100644 index 0000000..d8f6e57 --- /dev/null +++ b/relaykit/dto/testdata/wire/responses_stream_event_output_item_added.json @@ -0,0 +1,12 @@ +{ + "type": "response.output_item.added", + "output_index": 1, + "item": { + "type": "function_call", + "id": "fc_67f2a1b3c4d5e6f7", + "call_id": "call_abc123", + "name": "get_weather", + "arguments": "{\"city\":\"Shanghai\"}", + "status": "in_progress" + } +} diff --git a/relaykit/dto/testdata/wire/responses_stream_event_reasoning_part.json b/relaykit/dto/testdata/wire/responses_stream_event_reasoning_part.json new file mode 100644 index 0000000..2811143 --- /dev/null +++ b/relaykit/dto/testdata/wire/responses_stream_event_reasoning_part.json @@ -0,0 +1,10 @@ +{ + "type": "response.reasoning_summary_part.added", + "item_id": "rs_67f2a1b3c4d5e6f7", + "output_index": 0, + "summary_index": 0, + "part": { + "type": "summary_text", + "text": "Let me think..." + } +} diff --git a/relaykit/dto/testdata/wire/responses_stream_event_text_delta.json b/relaykit/dto/testdata/wire/responses_stream_event_text_delta.json new file mode 100644 index 0000000..f759fa2 --- /dev/null +++ b/relaykit/dto/testdata/wire/responses_stream_event_text_delta.json @@ -0,0 +1,7 @@ +{ + "type": "response.output_text.delta", + "item_id": "msg_67f2a1b3c4d5e6f7", + "output_index": 0, + "content_index": 0, + "delta": "Hello" +} diff --git a/relaykit/dto/wire_fidelity_test.go b/relaykit/dto/wire_fidelity_test.go new file mode 100644 index 0000000..6879938 --- /dev/null +++ b/relaykit/dto/wire_fidelity_test.go @@ -0,0 +1,203 @@ +// wire_fidelity_test.go — DTO wire 协议保真测试。 +// +// 目的:验证 DTO 结构体与各上游 API 的真实 wire 格式一致。测试语料 +// (testdata/wire/*.json)按官方 API 文档的字段形状手工编写,视为 wire 真相; +// 测试将语料解码进 DTO 再重新编码,逐 key 对比「语料 ⊆ 重编码结果」: +// - key 丢失 → DTO 缺字段或 json tag 拼错(如历史上的 thoughtBudget/thinkingBudget) +// - 值变形 → DTO 字段类型错误(如 wire 是对象却声明为 string) +// - 解码报错 → DTO 类型与 wire 类型冲突 +// +// 该测试是协议转换金样本套件的地基:转换测试的输入输出都以 DTO 表达, +// 若 DTO 本身偏离 wire 格式,金样本会把错误固化成「标准答案」。 +// 新增/修改 DTO 字段时,请同步在语料中补充该字段的官方示例值。 +package dto_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// wireCases 语料清单:文件名 → 解码目标 DTO 类型。 +// 新增语料文件后必须在此登记,未登记的 testdata/wire/*.json 会被测试报错提醒。 +var wireCases = map[string]func(t *testing.T, data []byte) any{ + "openai_chat_request_full.json": decodeInto[dto.GeneralOpenAIRequest], + "openai_chat_response_full.json": decodeInto[dto.ChatCompletionResponse], + "claude_request_full.json": decodeInto[dto.ClaudeRequest], + "claude_response_full.json": decodeInto[dto.ClaudeResponse], + "gemini_request_full.json": decodeInto[dto.GeminiChatRequest], + "gemini_response_full.json": decodeInto[dto.GeminiChatResponse], + "responses_request_full.json": decodeInto[dto.OpenAIResponsesRequest], + "responses_response_full.json": decodeInto[dto.OpenAIResponsesResponse], + "ollama_chat_request_full.json": decodeInto[dto.OllamaChatRequest], + "ollama_chat_response_full.json": decodeInto[dto.OllamaChatResponse], + + // 流式 chunk / 事件形状(流式转换器逐帧解码的目标类型) + "openai_chat_stream_chunk_full.json": decodeInto[dto.ChatCompletionStreamResponse], + "claude_stream_event_message_start.json": decodeInto[dto.ClaudeResponse], + "claude_stream_event_content_block_start.json": decodeInto[dto.ClaudeResponse], + "claude_stream_event_content_block_delta.json": decodeInto[dto.ClaudeResponse], + "claude_stream_event_thinking_delta.json": decodeInto[dto.ClaudeResponse], + "claude_stream_event_signature_delta.json": decodeInto[dto.ClaudeResponse], + "claude_stream_event_message_delta.json": decodeInto[dto.ClaudeResponse], + "gemini_stream_chunk_full.json": decodeInto[dto.GeminiChatResponse], + "responses_stream_event_text_delta.json": decodeInto[dto.ResponsesStreamResponse], + "responses_stream_event_output_item_added.json": decodeInto[dto.ResponsesStreamResponse], + "responses_stream_event_reasoning_part.json": decodeInto[dto.ResponsesStreamResponse], +} + +func decodeInto[T any](t *testing.T, data []byte) any { + t.Helper() + var v T + if err := json.Unmarshal(data, &v); err != nil { + t.Fatalf("解码进 DTO 失败(DTO 字段类型与 wire 冲突?): %v", err) + } + return v +} + +func TestWireFidelity(t *testing.T) { + files, err := os.ReadDir(filepath.Join("testdata", "wire")) + if err != nil { + t.Fatalf("读取 testdata/wire 目录失败: %v", err) + } + + seen := make(map[string]bool) + for _, f := range files { + if !strings.HasSuffix(f.Name(), ".json") { + continue + } + seen[f.Name()] = true + decode, ok := wireCases[f.Name()] + if !ok { + t.Errorf("语料文件 %s 未在 wireCases 登记解码类型", f.Name()) + continue + } + t.Run(f.Name(), func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "wire", f.Name())) + if err != nil { + t.Fatalf("读取语料失败: %v", err) + } + + // wire 真相(语料原文) + var want any + if err := json.Unmarshal(data, &want); err != nil { + t.Fatalf("语料不是合法 JSON: %v", err) + } + + // wire → DTO → wire 往返 + v := decode(t, data) + out, err := json.Marshal(v) + if err != nil { + t.Fatalf("DTO 重编码失败: %v", err) + } + var got any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("重编码结果不是合法 JSON: %v", err) + } + + // 语料 ⊆ 重编码结果:语料中的每个 key/值都必须在往返后幸存 + var diffs []string + subsetDiff("$", want, got, &diffs) + if len(diffs) > 0 { + t.Errorf("DTO 往返丢失/变形了 %d 处 wire 字段:\n %s", + len(diffs), strings.Join(diffs, "\n ")) + } + }) + } + + for name := range wireCases { + if !seen[name] { + t.Errorf("wireCases 登记的语料文件 %s 不存在", name) + } + } +} + +// subsetDiff 递归检查 want 的每个 key 路径在 got 中存在且值相等,差异追加进 diffs。 +// want 中值为 null 的 key 在 got 中缺失视为等价(omitempty 指针字段的正常行为)。 +func subsetDiff(path string, want, got any, diffs *[]string) { + if want == nil { + return // null ≈ 缺失/为 null,均可接受 + } + switch w := want.(type) { + case map[string]any: + g, ok := got.(map[string]any) + if !ok { + *diffs = append(*diffs, fmt.Sprintf("%s: wire 为对象,往返后为 %s", path, jsonTypeName(got))) + return + } + for k, wv := range w { + gv, exists := g[k] + if !exists { + if isJSONZero(wv) { + // omitempty 对零值(0/""/false/空数组/空对象/null)的正常省略: + // wire 语义上缺失 ≈ 零值,不算丢失。语料中语义敏感的字段请用非零值。 + continue + } + *diffs = append(*diffs, fmt.Sprintf("%s.%s: key 丢失(DTO 缺字段或 tag 拼错)", path, k)) + continue + } + subsetDiff(path+"."+k, wv, gv, diffs) + } + case []any: + g, ok := got.([]any) + if !ok { + *diffs = append(*diffs, fmt.Sprintf("%s: wire 为数组,往返后为 %s", path, jsonTypeName(got))) + return + } + if len(g) != len(w) { + *diffs = append(*diffs, fmt.Sprintf("%s: 数组长度 %d → %d", path, len(w), len(g))) + return + } + for i := range w { + subsetDiff(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], diffs) + } + default: + // 标量:两侧都经过 encoding/json,数字统一为 float64,直接比较 + if want != got { + *diffs = append(*diffs, fmt.Sprintf("%s: 值变形 %v → %v", path, want, got)) + } + } +} + +// isJSONZero 判断 wire 值是否为 JSON 零值(omitempty 会省略的形态)。 +func isJSONZero(v any) bool { + switch t := v.(type) { + case nil: + return true + case float64: + return t == 0 + case string: + return t == "" + case bool: + return !t + case []any: + return len(t) == 0 + case map[string]any: + return len(t) == 0 + } + return false +} + +func jsonTypeName(v any) string { + switch v.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case bool: + return "bool" + case float64: + return "number" + default: + return fmt.Sprintf("%T", v) + } +} diff --git a/relaykit/relayconvert/convmeta/meta.go b/relaykit/relayconvert/convmeta/meta.go index 9575c1a..8e89e62 100644 --- a/relaykit/relayconvert/convmeta/meta.go +++ b/relaykit/relayconvert/convmeta/meta.go @@ -20,10 +20,6 @@ type Meta interface { GetChannelID() int GetChannelType() int GetIsStream() bool - GetReasoningEffort() string - // SetReasoningEffort 记录转换器从模型名后缀推导出的 effort 级别, - // 以便下游计费/日志能够看到。 - SetReasoningEffort(effort string) GetEstimatePromptTokens() int // EnsureClaudeConvertInfo 懒创建并返回可变的 OpenAI→Claude 流式转换状态。 @@ -72,7 +68,6 @@ type Values struct { ChannelID int ChannelType int IsStream bool - ReasoningEffort string EstimatePromptTokens int ClaudeConvertInfo *ClaudeConvertInfo @@ -120,19 +115,6 @@ func (v *Values) GetIsStream() bool { return v != nil && v.IsStream } -func (v *Values) GetReasoningEffort() string { - if v == nil { - return "" - } - return v.ReasoningEffort -} - -func (v *Values) SetReasoningEffort(effort string) { - if v != nil { - v.ReasoningEffort = effort - } -} - func (v *Values) GetEstimatePromptTokens() int { if v == nil { return 0 diff --git a/relaykit/relayconvert/convmeta/meta_test.go b/relaykit/relayconvert/convmeta/meta_test.go index 70553bd..4c52e4a 100644 --- a/relaykit/relayconvert/convmeta/meta_test.go +++ b/relaykit/relayconvert/convmeta/meta_test.go @@ -18,12 +18,10 @@ func TestValuesTypedNilMetaIsSafe(t *testing.T) { assert.Zero(t, meta.GetChannelID()) assert.Zero(t, meta.GetChannelType()) assert.False(t, meta.GetIsStream()) - assert.Empty(t, meta.GetReasoningEffort()) assert.Zero(t, meta.GetEstimatePromptTokens()) assert.Zero(t, meta.GetSendResponseCount()) require.NotPanics(t, func() { - meta.SetReasoningEffort("high") meta.IncrSendResponseCount() meta.AppendRequestConversion(types.RelayFormatClaude) }) @@ -82,18 +80,6 @@ func TestGeminiOptions_SafetySettingFor(t *testing.T) { assert.Empty(t, opts.SafetySettingFor("other")) } -func TestOptions_ShouldPreserveThinkingSuffix(t *testing.T) { - var nilOpts *Options - assert.False(t, nilOpts.ShouldPreserveThinkingSuffix("m")) - - opts := &Options{} - assert.False(t, opts.ShouldPreserveThinkingSuffix("m")) - - opts.PreserveThinkingSuffix = func(model string) bool { return model == "keep-me" } - assert.True(t, opts.ShouldPreserveThinkingSuffix("keep-me")) - assert.False(t, opts.ShouldPreserveThinkingSuffix("other")) -} - // --- Values 行为 --- func TestValues_AppendRequestConversion_Dedup(t *testing.T) { @@ -123,10 +109,8 @@ func TestValues_ConvOptions_Lazy(t *testing.T) { assert.Same(t, opts, v.ConvOptions()) } -func TestValues_SetReasoningEffortAndIncr(t *testing.T) { +func TestValues_IncrSendResponseCount(t *testing.T) { v := &Values{} - v.SetReasoningEffort("high") - assert.Equal(t, "high", v.GetReasoningEffort()) assert.Zero(t, v.GetSendResponseCount()) v.IncrSendResponseCount() v.IncrSendResponseCount() diff --git a/relaykit/relayconvert/convmeta/options.go b/relaykit/relayconvert/convmeta/options.go index f78fb4c..998a7a9 100644 --- a/relaykit/relayconvert/convmeta/options.go +++ b/relaykit/relayconvert/convmeta/options.go @@ -11,20 +11,9 @@ type Options struct { // 该接口接受转换器仅在该方言下才会输出的额外字段(reasoning 配置、system parts 上的 cache_control)。 // 宿主根据渠道类型设置此项。 OpenRouterDialect bool - - // PreserveThinkingSuffix 返回哪些模型在发出的模型名上必须保留 - // -thinking/-nothinking/effort 后缀(宿主黑名单查询)。 - // Nil 表示"从不保留"。 - PreserveThinkingSuffix func(modelName string) bool } type ClaudeOptions struct { - // ThinkingAdapterEnabled 将带 "-thinking" 后缀的 OpenAI 模型名 - // 转换为 Claude 扩展思考请求。 - ThinkingAdapterEnabled bool - // ThinkingAdapterBudgetTokensPercentage 在适配器触发时, - // 按 max_tokens 的一定比例确定 thinking budget_tokens 的大小。 - ThinkingAdapterBudgetTokensPercentage float64 // DefaultMaxTokens 返回当源请求未携带 max_tokens 时要注入的 max_tokens 值。 // Claude Messages API 要求必须提供 max_tokens(省略会返回 400), // 因此当该 hook 为 nil 且没有其他路径提供该值时, @@ -35,12 +24,6 @@ type ClaudeOptions struct { } type GeminiOptions struct { - // ThinkingAdapterEnabled 将 -thinking/-nothinking/effort 后缀映射到 - // Gemini 的 thinkingConfig。 - ThinkingAdapterEnabled bool - // ThinkingAdapterBudgetTokensPercentage 在适配器触发时, - // 按 maxOutputTokens 的一定比例确定 thinkingBudget 的大小。 - ThinkingAdapterBudgetTokensPercentage float64 // FunctionCallThoughtSignatureEnabled 将 thoughtSignature 绕过值 // 附带在 function-call parts 上。 FunctionCallThoughtSignatureEnabled bool @@ -50,6 +33,11 @@ type GeminiOptions struct { // SafetySetting 返回某个类别对应的伤害阈值。 // 返回 nil 或空字符串表示不附带 safetySettings。 SafetySetting func(category string) string + // WebSearchToGoogleSearch 渠道级开关:将入站请求的服务端 web_search 工具 + // 映射为 Gemini 原生 googleSearch 工具(第一档:仅请求侧;响应中的 + // groundingMetadata 不还原为工具块,客户端得到被搜索增强的纯文本)。 + // grounded 请求 Google 在 token 之外按搜索次数单独计价,故默认关闭、按渠道显式开启。 + WebSearchToGoogleSearch bool } func (o *ClaudeOptions) DefaultMaxTokensFor(modelName string) (int, bool) { @@ -69,7 +57,3 @@ func (o *GeminiOptions) SafetySettingFor(category string) string { } return o.SafetySetting(category) } - -func (o *Options) ShouldPreserveThinkingSuffix(modelName string) bool { - return o != nil && o.PreserveThinkingSuffix != nil && o.PreserveThinkingSuffix(modelName) -} diff --git a/relaykit/relayconvert/convmeta/responses_stash.go b/relaykit/relayconvert/convmeta/responses_stash.go new file mode 100644 index 0000000..2083841 --- /dev/null +++ b/relaykit/relayconvert/convmeta/responses_stash.go @@ -0,0 +1,19 @@ +package convmeta + +import ( + "github.com/qianfree/team-api/relaykit/dto" +) + +// ResponsesStash 是宿主可选实现的能力接口:缓存 Responses 入站请求的解析快照, +// 供「上游 chat 响应合成回 Responses 格式」时 echo 请求参数 +// (temperature / top_p / max_output_tokens / instructions)。 +// +// 转换器对 Meta 做类型断言获取本接口;info 未实现(或未 stash)时静默跳过, +// echo 回退 OpenAI 默认值(temperature=1.0 / top_p=1.0,其余 nil)。 +// 宿主端由 *relaycommon.RelayInfo 实现(对应旧字段 info.ResponsesRequest)。 +type ResponsesStash interface { + // StashResponsesRequest 存入最新一次解析后的 Responses 请求快照(覆盖旧值)。 + StashResponsesRequest(req *dto.OpenAIResponsesRequest) + // StashedResponsesRequest 返回已 stash 的请求快照;未 stash 时返回 nil。 + StashedResponsesRequest() *dto.OpenAIResponsesRequest +} diff --git a/relaykit/relayconvert/engine.go b/relaykit/relayconvert/engine.go new file mode 100644 index 0000000..d7c0f91 --- /dev/null +++ b/relaykit/relayconvert/engine.go @@ -0,0 +1,49 @@ +// Package relayconvert — 请求转换执行引擎。 +// +// 注册表支持两类请求转换器:直接转换器(Convert 非空)与步骤链转换器 +// (StepConverters 非空,注册时已校验 From/To 连续性)。本文件提供统一的 +// 执行入口:宿主桥接层只需持有转换器 ID,无需关心其为直接实现还是 +// 「经 OpenAI 中枢两跳」的链式组合。 +package relayconvert + +import ( + "context" + "fmt" + + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// ConvertRequestByID 按转换器 ID 执行请求转换(直接或步骤链)。 +// request 为入站格式的已解析 DTO;返回目标格式 DTO。 +func ConvertRequestByID(ctx context.Context, info convmeta.Meta, converterID string, request any) (any, error) { + spec, ok := LookupRequestConverter(converterID) + if !ok { + return nil, fmt.Errorf("request converter %q is not registered", converterID) + } + return ExecuteRequestConversion(ctx, info, spec, request) +} + +// ExecuteRequestConversion 执行一个请求转换器 spec:直接转换器调用其 Convert; +// 步骤链按注册顺序依次执行各步骤,中间值逐步传递(各步骤的 DTO 出入参 +// 由注册时的 From/To 连续性校验保证兼容)。 +func ExecuteRequestConversion(ctx context.Context, info convmeta.Meta, spec RequestConverterSpec, request any) (any, error) { + if spec.Convert != nil { + return spec.Convert(ctx, info, request) + } + if len(spec.StepConverters) == 0 { + return nil, fmt.Errorf("request converter %q has no convert implementation", spec.ID) + } + current := request + for _, stepID := range spec.StepConverters { + step, ok := LookupRequestConverter(stepID) + if !ok || step.Convert == nil { + return nil, fmt.Errorf("request converter %q step %q is not a registered direct converter", spec.ID, stepID) + } + next, err := step.Convert(ctx, info, current) + if err != nil { + return nil, fmt.Errorf("request converter %q step %q: %w", spec.ID, stepID, err) + } + current = next + } + return current, nil +} diff --git a/relaykit/relayconvert/errors.go b/relaykit/relayconvert/errors.go new file mode 100644 index 0000000..18d81e6 --- /dev/null +++ b/relaykit/relayconvert/errors.go @@ -0,0 +1,25 @@ +// Package relayconvert — 转换器哨兵错误。 +// +// internal 转换器包宿主无法 import,哨兵错误必须定义在可导出的顶层包中, +// 宿主才能用 errors.Is 识别并映射为自身的错误语义。 +package relayconvert + +import "errors" + +// ErrStatefulResponsesUnsupported responses 有状态请求(previous_response_id)落在 +// 不支持 Responses 协议的 chat-only 渠道上:会话历史存储在上游 Responses 服务侧, +// 降级转换会静默丢失全部上下文,必须快速失败。 +// +// 由 internal/oai_responses 的 ResponsesToOpenAIRequestConverter wrap 返回; +// 语义与宿主 relay/constant.ErrStatefulResponsesUnsupported 一致,宿主捕获转换错误后 +// 需用 errors.Is 识别本哨兵并按渠道级致命上报调度 FSM 换渠道。 +var ErrStatefulResponsesUnsupported = errors.New("previous_response_id requires a responses-native channel") + +// ErrContentBlocked 上游以内容安全策略拒绝了本次请求(如 Gemini 的 +// promptFeedback.blockReason=SAFETY)。这是**客户端内容**问题而非渠道故障: +// 宿主必须映射为请求类错误(4xx 语义),不得按 5xx 上游故障上报调度 FSM, +// 否则正常渠道会因用户发送违规提示词而被扣健康分乃至熔断。 +// +// 由 internal/{claude_gemini,native_responses,oai_gemini} 的 Gemini 出站转换器 +// wrap 返回(流式与非流式各 3 处);宿主用 errors.Is 识别。 +var ErrContentBlocked = errors.New("content blocked by upstream safety policy") diff --git a/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request.go b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request.go new file mode 100644 index 0000000..439ae02 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request.go @@ -0,0 +1,522 @@ +package claude_gemini + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +const ( + // claudeMaxTokensFallback 源请求未携带 max_tokens 时的兜底值(与 Claude→OpenAI 跳同口径) + claudeMaxTokensFallback = 4096 + // geminiMaxStopSequences Gemini generationConfig.stopSequences 上限 + geminiMaxStopSequences = 5 +) + +// ClaudeToGeminiRequestConverter 将 Claude Messages 请求直连转换为 Gemini GenerateContent 请求。 +// +// 与「Claude→OpenAI→Gemini」步骤链相比,直连不经中枢格式,故能保住 OpenAI 无法承载的信息: +// - thinking.signature → part.thoughtSignature:Gemini 多轮思考续传的凭据, +// 链式在 Claude→OpenAI 跳被丢弃,回传时思考块缺签名会被上游判为非法请求 +// - thinking.budget_tokens → thinkingBudget 精确值:链式先折叠为 low/medium/high +// 再展开为 1024/8192/32768,5000 会被放大成 8192 +// - top_k → generationConfig.topK:OpenAI chat 无该字段,链式直接丢失 +// - tool_use.id → functionCall.id:链式经 OpenAI tool_calls 后被 Gemini 跳丢弃 +// - system 文本块边界:链式压平为单个字符串 +type ClaudeToGeminiRequestConverter struct{} + +func (c *ClaudeToGeminiRequestConverter) ID() string { + return relayconvert.ConverterClaudeMessagesToGeminiContent +} + +func (c *ClaudeToGeminiRequestConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToGeminiRequestConverter) To() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *ClaudeToGeminiRequestConverter) Quality() relayconvert.RequestConverterQuality { + return relayconvert.RequestConverterQualityFair +} + +func (c *ClaudeToGeminiRequestConverter) ConvertRequest( + ctx context.Context, + info convmeta.Meta, + request any, +) (any, error) { + claudeReq, ok := request.(*dto.ClaudeRequest) + if !ok { + return nil, fmt.Errorf("expected *dto.ClaudeRequest, got %T", request) + } + + // Gemini 的目标模型经 URL 路径传递(请求体无 model 字段),故无需写入上游模型名 + geminiReq := &dto.GeminiChatRequest{ + Contents: convertClaudeMessagesToContents(claudeReq.Messages), + GenerationConfig: &dto.GeminiGenerationConfig{ + Temperature: claudeReq.Temperature, + TopP: claudeReq.TopP, + }, + } + + maxTokens := claudeMaxTokensFallback + if claudeReq.MaxTokens != nil { + maxTokens = int(*claudeReq.MaxTokens) + } + if maxTokens > 0 { + v := uint(maxTokens) + geminiReq.GenerationConfig.MaxOutputTokens = &v + } + + // top_k → topK(Claude 与 Gemini 均有该字段,链式因中枢缺字段而丢失) + if claudeReq.TopK != nil && *claudeReq.TopK > 0 { + v := float64(*claudeReq.TopK) + geminiReq.GenerationConfig.TopK = &v + } + + // stop_sequences:Gemini 上限 5 个,超出截断(与链式同口径) + if len(claudeReq.StopSequences) > 0 { + stops := claudeReq.StopSequences + if len(stops) > geminiMaxStopSequences { + stops = stops[:geminiMaxStopSequences] + } + geminiReq.GenerationConfig.StopSequences = stops + } + + if thinking := claudeThinkingToGemini(claudeReq.Thinking); thinking != nil { + geminiReq.GenerationConfig.ThinkingConfig = thinking + } + + // 默认宽松安全设置(与 OpenAI→Gemini 路径同口径,渠道策略不因入站格式而变) + geminiReq.SafetySettings = defaultGeminiSafetySettings() + + if len(claudeReq.Tools) > 0 { + mapWebSearch := convmeta.OptionsOf(info).Gemini.WebSearchToGoogleSearch + toolsJSON, hasFuncDecls, err := claudeToolsToGemini(claudeReq.Tools, mapWebSearch) + if err != nil { + return nil, fmt.Errorf("convert tools: %w", err) + } + if len(toolsJSON) > 0 { + geminiReq.Tools = toolsJSON + // toolConfig(functionCallingConfig)只对函数声明有意义,纯 googleSearch 时不附加 + if hasFuncDecls && claudeReq.ToolChoice != nil { + geminiReq.ToolConfig = claudeToolChoiceToGemini(claudeReq.ToolChoice) + } + } + } + + if system := claudeSystemToGemini(claudeReq.System); system != nil { + geminiReq.SystemInstruction = system + } + + return geminiReq, nil +} + +// ========== generationConfig 映射 ========== + +// claudeThinkingToGemini 将 Claude thinking 配置转换为 Gemini thinkingConfig。 +// 与链式(budget→reasoning_effort→budget)不同,此处 budget 原值直传。 +// 只下发 thinkingBudget、不下发 thinkingLevel:二者互斥(同时携带上游返回 400), +// 且 Gemini 3 对 thinkingBudget 向后兼容,budget 是各代模型的公共表达。 +// type 非 enabled(disabled / 缺省)时不附加 thinkingConfig,与链式同口径。 +func claudeThinkingToGemini(thinking *dto.ClaudeThinking) *dto.GeminiThinkingConfig { + if thinking == nil || thinking.Type != "enabled" { + return nil + } + cfg := &dto.GeminiThinkingConfig{IncludeThoughts: true} + if thinking.BudgetTokens != nil && *thinking.BudgetTokens > 0 { + cfg.ThinkingBudget = intPtr(*thinking.BudgetTokens) + } + return cfg +} + +// defaultGeminiSafetySettings 返回宽松的默认安全设置。 +func defaultGeminiSafetySettings() []dto.GeminiSafetySetting { + return []dto.GeminiSafetySetting{ + {Category: "HARM_CATEGORY_HARASSMENT", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_HATE_SPEECH", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", Threshold: "BLOCK_ONLY_HIGH"}, + {Category: "HARM_CATEGORY_DANGEROUS_CONTENT", Threshold: "BLOCK_ONLY_HIGH"}, + } +} + +// ========== tools / tool_choice 映射 ========== + +// claudeToolsToGemini 将 Claude tools 序列化为 Gemini 的 tools 字段 +// (json.RawMessage 形态的 [{functionDeclarations: [...]}, ...]——tools 在 +// Gemini API 中是 repeated Tool,必须是数组,单对象形态会被严格解析拒绝)。 +// 自定义工具转为 functionDeclarations;服务端内置工具中,web_search 在渠道开启 +// 映射时转为 Gemini 原生 googleSearch(第一档:仅请求侧,响应侧 grounding 不还原 +// 为工具块),computer 等其余内置工具无对应物,跳过。 +// 第二个返回值表示是否含函数声明(决定 toolConfig 是否有意义)。 +func claudeToolsToGemini(tools []dto.ClaudeTool, mapWebSearch bool) (json.RawMessage, bool, error) { + decls := make([]dto.GeminiFunctionDeclaration, 0, len(tools)) + hasWebSearch := false + for _, t := range tools { + if t.Name == "" || (t.Type != "" && t.Type != "custom") { + // web_search 内置工具带版本后缀(如 web_search_20250305),按前缀识别 + if strings.HasPrefix(t.Type, "web_search") { + hasWebSearch = true + } + continue + } + decls = append(decls, dto.GeminiFunctionDeclaration{ + Name: t.Name, + Description: t.Description, + Parameters: shared.CleanGeminiToolParams(t.InputSchema), + }) + } + entries := make([]map[string]any, 0, 2) + if len(decls) > 0 { + entries = append(entries, map[string]any{"functionDeclarations": decls}) + } else if mapWebSearch && hasWebSearch { + // 保守策略:仅在无自定义函数时附加 googleSearch——部分模型代际不接受 + // googleSearch 与 functionDeclarations 同请求,混用时宁可丢搜索也不整请求 400。 + // Claude 侧的 max_uses / allowed_domains 等参数无 Gemini 对应物,静默降级。 + entries = append(entries, map[string]any{"googleSearch": map[string]any{}}) + } + if len(entries) == 0 { + return nil, false, nil + } + raw, err := json.Marshal(entries) + if err != nil { + return nil, false, err + } + return raw, len(decls) > 0, nil +} + +// claudeToolChoiceToGemini 将 Claude tool_choice 转换为 Gemini toolConfig。 +func claudeToolChoiceToGemini(toolChoice any) any { + tcType, name := claudeToolChoiceFields(toolChoice) + functionCallingConfig := map[string]any{"mode": "AUTO"} + switch tcType { + case "any": + functionCallingConfig["mode"] = "ANY" + case "none": + functionCallingConfig["mode"] = "NONE" + case "tool": + functionCallingConfig["mode"] = "ANY" + if name != "" { + functionCallingConfig["allowedFunctionNames"] = []string{name} + } + } + return map[string]any{"functionCallingConfig": functionCallingConfig} +} + +// claudeToolChoiceFields 读取 tool_choice 的 type / name,兼容字符串与对象两种入站形态。 +func claudeToolChoiceFields(toolChoice any) (tcType, name string) { + switch v := toolChoice.(type) { + case string: + return v, "" + case map[string]any: + t, _ := v["type"].(string) + n, _ := v["name"].(string) + return t, n + case *dto.ClaudeToolChoice: + if v == nil { + return "", "" + } + return v.Type, v.Name + case dto.ClaudeToolChoice: + return v.Type, v.Name + } + return "", "" +} + +// ========== system / messages 映射 ========== + +// claudeSystemToGemini 将 Claude system 转换为 Gemini systemInstruction。 +// 与链式(压平为单个字符串)不同,此处保留文本块的划分。 +func claudeSystemToGemini(system any) *dto.GeminiContent { + var texts []string + switch v := system.(type) { + case nil: + return nil + case string: + if v != "" { + texts = append(texts, v) + } + case []any: + for _, item := range v { + m, ok := item.(map[string]any) + if !ok || m["type"] != "text" { + continue + } + if text, ok := m["text"].(string); ok && text != "" { + texts = append(texts, text) + } + } + case []dto.ClaudeContentBlock: + for i := range v { + if v[i].Type == "text" && v[i].Text != nil && *v[i].Text != "" { + texts = append(texts, *v[i].Text) + } + } + } + if len(texts) == 0 { + return nil + } + parts := make([]dto.GeminiPart, 0, len(texts)) + for _, text := range texts { + parts = append(parts, dto.GeminiPart{Text: text}) + } + return &dto.GeminiContent{Parts: parts} +} + +// convertClaudeMessagesToContents 将 Claude messages 转换为 Gemini contents。 +// Claude 的 user/assistant 分别落到 Gemini 的 user/model。 +func convertClaudeMessagesToContents(messages []dto.ClaudeMessage) []dto.GeminiContent { + // tool_result 块只带 tool_use_id,而 Gemini 的 functionResponse 用函数名关联, + // 需先扫一遍 assistant 的 tool_use 块建立 id→name 映射。 + toolNames := claudeToolUseNames(messages) + + contents := make([]dto.GeminiContent, 0, len(messages)) + for i := range messages { + blocks := c2gNormalizeContent(messages[i].Content) + var parts []dto.GeminiPart + role := "" + switch messages[i].Role { + case "user": + role, parts = "user", claudeUserParts(blocks, toolNames) + case "assistant": + role, parts = "model", claudeModelParts(blocks) + } + // Gemini 拒绝 parts 为空的 content,整条消息无可搬运内容时跳过 + if len(parts) > 0 { + contents = append(contents, dto.GeminiContent{Role: role, Parts: parts}) + } + } + return contents +} + +// claudeToolUseNames 建立 tool_use_id → 函数名映射(供 functionResponse 关联)。 +func claudeToolUseNames(messages []dto.ClaudeMessage) map[string]string { + names := make(map[string]string) + for i := range messages { + if messages[i].Role != "assistant" { + continue + } + for _, b := range c2gNormalizeContent(messages[i].Content) { + if b.Type == "tool_use" && b.ID != "" && b.Name != "" { + names[b.ID] = b.Name + } + } + } + return names +} + +// claudeUserParts 转换 Claude user 消息的内容块。 +// tool_result 落到 functionResponse,Gemini 要求其位于 user content 内, +// 而本函数产出的整个 content 即 role=user,故文本块与工具结果可共存于同一条 content。 +func claudeUserParts(blocks []c2gBlock, toolNames map[string]string) []dto.GeminiPart { + parts := make([]dto.GeminiPart, 0, len(blocks)) + for _, b := range blocks { + switch b.Type { + case "text": + if b.Text != "" { + parts = append(parts, dto.GeminiPart{Text: b.Text}) + } + case "image", "document": + if part, ok := claudeMediaPart(b.Source); ok { + parts = append(parts, part) + } + case "tool_result": + name := toolNames[b.ToolUseID] + if name == "" { + // 找不到对应 tool_use(历史被截断等):Gemini 只能按名关联,无法搬运 + continue + } + parts = append(parts, dto.GeminiPart{ + FunctionResponse: &dto.GeminiFunctionResponse{ + ID: b.ToolUseID, + Name: name, + Response: claudeToolResultToResponse(b.Result), + }, + }) + } + } + return parts +} + +// claudeModelParts 转换 Claude assistant 消息的内容块。 +func claudeModelParts(blocks []c2gBlock) []dto.GeminiPart { + parts := make([]dto.GeminiPart, 0, len(blocks)) + for _, b := range blocks { + switch b.Type { + case "text": + if b.Text != "" { + parts = append(parts, dto.GeminiPart{Text: b.Text}) + } + case "thinking": + if b.Thinking == "" { + continue + } + parts = append(parts, dto.GeminiPart{ + Text: b.Thinking, + Thought: boolPtr(true), + ThoughtSignature: b.Signature, + }) + case "redacted_thinking": + // Claude 的加密思考块,Gemini 无对应物,丢弃(与响应侧同口径) + case "tool_use": + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + ID: b.ID, + FunctionName: b.Name, + Arguments: claudeToolInput(b.Input), + }, + }) + } + } + return parts +} + +// claudeMediaPart 将 Claude 的多模态 source 转换为 Gemini part。 +// base64 数据走 inlineData;URL 走 fileData(Gemini 的 fileData 官方只接受 +// Files API URI,非 Files URI 的地址由上游判定,此处原样搬运而非静默丢弃)。 +func claudeMediaPart(source *dto.ClaudeSource) (dto.GeminiPart, bool) { + if source == nil { + return dto.GeminiPart{}, false + } + switch { + case source.Data != "": + return dto.GeminiPart{InlineData: &dto.GeminiInlineData{ + MimeType: source.MediaType, + Data: source.Data, + }}, true + case source.URL != "": + return dto.GeminiPart{FileData: &dto.GeminiFileData{ + MimeType: source.MediaType, + FileURI: source.URL, + }}, true + } + return dto.GeminiPart{}, false +} + +// claudeToolResultToResponse 委托 shared.ToolResultToGeminiResponse(单源实现): +// Gemini 要求 response 是对象,非对象结果统一包成 {"result": ...}。 +func claudeToolResultToResponse(content any) any { + return shared.ToolResultToGeminiResponse(content) +} + +// ========== 内容块归一化 ========== + +// c2gBlock Claude 内容块的归一化视图。 +// 入站 message.content 在宿主侧解码后有 []any(原始 JSON)与 []dto.ClaudeContentBlock +// (已解析类型)两种形态,统一为一种形态,避免下游逐处断言。 +type c2gBlock struct { + Type string + Text string + Thinking string + Signature string + ID string + Name string + Input any + ToolUseID string + Result any + Source *dto.ClaudeSource +} + +// c2gNormalizeContent 将 Claude message.content 归一化为内容块列表, +// 字符串形态视为单个 text 块。 +func c2gNormalizeContent(content any) []c2gBlock { + switch v := content.(type) { + case string: + if v == "" { + return nil + } + return []c2gBlock{{Type: "text", Text: v}} + case []any: + blocks := make([]c2gBlock, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + blocks = append(blocks, c2gBlockFromMap(m)) + } + } + return blocks + case []map[string]any: + blocks := make([]c2gBlock, 0, len(v)) + for _, m := range v { + blocks = append(blocks, c2gBlockFromMap(m)) + } + return blocks + case []dto.ClaudeContentBlock: + blocks := make([]c2gBlock, 0, len(v)) + for i := range v { + blocks = append(blocks, c2gBlockFromTyped(&v[i])) + } + return blocks + } + return nil +} + +// c2gBlockFromMap 从原始 JSON map 形态提取内容块。 +func c2gBlockFromMap(m map[string]any) c2gBlock { + b := c2gBlock{ + Type: c2gString(m["type"]), + Text: c2gString(m["text"]), + Thinking: c2gString(m["thinking"]), + Signature: c2gString(m["signature"]), + ID: c2gString(m["id"]), + Name: c2gString(m["name"]), + Input: m["input"], + ToolUseID: c2gString(m["tool_use_id"]), + Result: m["content"], + } + if source, ok := m["source"].(map[string]any); ok { + b.Source = &dto.ClaudeSource{ + Type: c2gString(source["type"]), + MediaType: c2gString(source["media_type"]), + Data: c2gString(source["data"]), + URL: c2gString(source["url"]), + } + } + return b +} + +// c2gBlockFromTyped 从已解析的类型化形态提取内容块。 +func c2gBlockFromTyped(block *dto.ClaudeContentBlock) c2gBlock { + b := c2gBlock{ + Type: block.Type, + Signature: block.Signature, + ID: block.ID, + Name: block.Name, + Input: block.Input, + ToolUseID: block.ToolUseID, + Result: block.Content, + Source: block.Source, + } + if block.Text != nil { + b.Text = *block.Text + } + if block.Thinking != nil { + b.Thinking = *block.Thinking + } + return b +} + +// c2gString 读取值为字符串,非字符串返回空串。 +func c2gString(v any) string { + s, _ := v.(string) + return s +} + +// c2gJoinText 用换行符拼接文本片段。 +func c2gJoinText(parts []string) string { + joined := make([]byte, 0, 64) + for i, p := range parts { + if i > 0 { + joined = append(joined, '\n') + } + joined = append(joined, p...) + } + return string(joined) +} diff --git a/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request_test.go b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request_test.go new file mode 100644 index 0000000..10f1747 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_request_test.go @@ -0,0 +1,480 @@ +package claude_gemini + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_chat" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_gemini" +) + +// c2gUintPtr 返回 uint 的指针 +func c2gUintPtr(v uint) *uint { return &v } + +// runClaudeToGeminiRequest 跑直连请求转换并断言产物类型 +func runClaudeToGeminiRequest(t *testing.T, req *dto.ClaudeRequest) *dto.GeminiChatRequest { + t.Helper() + conv := &ClaudeToGeminiRequestConverter{} + got, err := conv.ConvertRequest(context.Background(), claudeInboundMeta(false), req) + if err != nil { + t.Fatalf("转换失败: %v", err) + } + geminiReq, ok := got.(*dto.GeminiChatRequest) + if !ok { + t.Fatalf("产物类型应为 *dto.GeminiChatRequest, got %T", got) + } + return geminiReq +} + +// TestClaudeToGeminiRequest_BasicConversation 校验 system 块结构、角色映射与生成参数。 +func TestClaudeToGeminiRequest_BasicConversation(t *testing.T) { + req := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + MaxTokens: c2gUintPtr(1024), + System: []any{ + map[string]any{"type": "text", "text": "你是助手"}, + map[string]any{"type": "text", "text": "回答简洁"}, + }, + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: []any{map[string]any{"type": "text", "text": "有什么可以帮你"}}}, + {Role: "user", Content: []any{map[string]any{"type": "text", "text": "继续"}}}, + }, + } + got := runClaudeToGeminiRequest(t, req) + + if got.SystemInstruction == nil || len(got.SystemInstruction.Parts) != 2 { + t.Fatalf("systemInstruction 应保留两个文本块(链式会压平为一个), got %+v", got.SystemInstruction) + } + if got.SystemInstruction.Parts[1].Text != "回答简洁" { + t.Errorf("system 第二块文本错误: %+v", got.SystemInstruction.Parts[1]) + } + + if len(got.Contents) != 3 { + t.Fatalf("contents 数量应为 3, got %d", len(got.Contents)) + } + roles := []string{got.Contents[0].Role, got.Contents[1].Role, got.Contents[2].Role} + if roles[0] != "user" || roles[1] != "model" || roles[2] != "user" { + t.Errorf("角色映射应为 user/model/user, got %v", roles) + } + if got.Contents[0].Parts[0].Text != "你好" { + t.Errorf("首条 user 文本错误: %+v", got.Contents[0].Parts[0]) + } + + if got.GenerationConfig.MaxOutputTokens == nil || *got.GenerationConfig.MaxOutputTokens != 1024 { + t.Errorf("maxOutputTokens 应为 1024, got %+v", got.GenerationConfig.MaxOutputTokens) + } +} + +// TestClaudeToGeminiRequest_MaxTokensFallback 源请求缺 max_tokens 时兜底 4096。 +func TestClaudeToGeminiRequest_MaxTokensFallback(t *testing.T) { + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + Messages: []dto.ClaudeMessage{{Role: "user", Content: "hi"}}, + }) + if got.GenerationConfig.MaxOutputTokens == nil || *got.GenerationConfig.MaxOutputTokens != claudeMaxTokensFallback { + t.Errorf("缺省 max_tokens 应兜底为 %d, got %+v", + claudeMaxTokensFallback, got.GenerationConfig.MaxOutputTokens) + } +} + +// TestClaudeToGeminiRequest_Thinking 校验 budget 原值直传与 thinking 签名搬运。 +func TestClaudeToGeminiRequest_Thinking(t *testing.T) { + budget := 5000 + req := &dto.ClaudeRequest{ + Thinking: &dto.ClaudeThinking{Type: "enabled", BudgetTokens: &budget}, + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "问题"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "让我想想", "signature": "sig-abc"}, + map[string]any{"type": "text", "text": "答案是 42"}, + }}, + }, + } + got := runClaudeToGeminiRequest(t, req) + + tc := got.GenerationConfig.ThinkingConfig + if tc == nil { + t.Fatal("thinkingConfig 未生成") + } + if tc.ThinkingBudget == nil || *tc.ThinkingBudget != 5000 { + t.Errorf("thinkingBudget 应为精确值 5000(链式会放大为 8192), got %+v", tc.ThinkingBudget) + } + if tc.ThinkingLevel != "" { + t.Errorf("不应下发 thinkingLevel(与 thinkingBudget 互斥,上游 400), got %q", tc.ThinkingLevel) + } + if !tc.IncludeThoughts { + t.Error("includeThoughts 应为 true") + } + + // wire 层校验:官方字段名为 thinkingBudget(thoughtBudget 不存在,上游严格解析会 400) + wire, err := json.Marshal(got.GenerationConfig) + if err != nil { + t.Fatalf("generationConfig 序列化失败: %v", err) + } + if !strings.Contains(string(wire), `"thinkingBudget":5000`) { + t.Errorf("wire 字段名应为 thinkingBudget, got %s", wire) + } + + parts := got.Contents[1].Parts + if len(parts) != 2 { + t.Fatalf("assistant parts 数量应为 2, got %d", len(parts)) + } + if parts[0].Thought == nil || !*parts[0].Thought { + t.Errorf("思考块 thought 标记缺失: %+v", parts[0]) + } + if parts[0].ThoughtSignature != "sig-abc" { + t.Errorf("thinking 签名应搬运到 thoughtSignature(链式会丢失), got %q", parts[0].ThoughtSignature) + } +} + +// TestClaudeToGeminiRequest_ThinkingDisabled 非 enabled 时不附加 thinkingConfig。 +func TestClaudeToGeminiRequest_ThinkingDisabled(t *testing.T) { + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + Thinking: &dto.ClaudeThinking{Type: "disabled"}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "hi"}}, + }) + if got.GenerationConfig.ThinkingConfig != nil { + t.Errorf("thinking.type=disabled 不应生成 thinkingConfig, got %+v", got.GenerationConfig.ThinkingConfig) + } +} + +// TestClaudeToGeminiRequest_ToolsAndToolResult 校验工具声明、tool_choice 与工具结果回传。 +func TestClaudeToGeminiRequest_ToolsAndToolResult(t *testing.T) { + req := &dto.ClaudeRequest{ + Tools: []dto.ClaudeTool{ + { + Name: "get_weather", + Description: "查天气", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []any{"city"}, + "additionalProperties": false, // Gemini 不支持,应被过滤 + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + }, + {Name: "web_search", Type: "web_search_20250305"}, // 内置工具无 schema,应跳过 + }, + ToolChoice: map[string]any{"type": "tool", "name": "get_weather"}, + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "北京天气"}, + {Role: "assistant", Content: []any{map[string]any{ + "type": "tool_use", "id": "toolu_01", "name": "get_weather", + "input": map[string]any{"city": "北京"}, + }}}, + {Role: "user", Content: []any{map[string]any{ + "type": "tool_result", "tool_use_id": "toolu_01", + "content": []any{map[string]any{"type": "text", "text": "晴 25 度"}}, + }}}, + }, + } + got := runClaudeToGeminiRequest(t, req) + + var toolEntries []struct { + FunctionDeclarations []dto.GeminiFunctionDeclaration `json:"functionDeclarations"` + } + if err := json.Unmarshal(got.Tools, &toolEntries); err != nil { + t.Fatalf("tools 应为数组形态(Gemini repeated Tool), 反序列化失败: %v", err) + } + if len(toolEntries) != 1 || len(toolEntries[0].FunctionDeclarations) != 1 { + t.Fatalf("应只保留 1 个函数声明(内置工具默认跳过), got %+v", toolEntries) + } + params, _ := toolEntries[0].FunctionDeclarations[0].Parameters.(map[string]any) + if _, ok := params["additionalProperties"]; ok { + t.Error("additionalProperties 应被过滤(Gemini 不支持)") + } + if _, ok := params["$schema"]; ok { + t.Error("$schema 应被过滤(Gemini 不支持)") + } + if _, ok := params["properties"]; !ok { + t.Error("properties 应保留") + } + + toolConfig, _ := got.ToolConfig.(map[string]any) + fcc, _ := toolConfig["functionCallingConfig"].(map[string]any) + if fcc["mode"] != "ANY" { + t.Errorf("tool_choice type=tool 应映射为 ANY, got %+v", fcc) + } + if names, _ := fcc["allowedFunctionNames"].([]string); len(names) != 1 || names[0] != "get_weather" { + t.Errorf("allowedFunctionNames 应为 [get_weather], got %+v", fcc["allowedFunctionNames"]) + } + + call := got.Contents[1].Parts[0].FunctionCall + if call == nil { + t.Fatal("assistant tool_use 应转换为 functionCall") + } + if call.ID != "toolu_01" { + t.Errorf("functionCall.id 应搬运 Claude 的 tool_use.id(链式会丢失), got %q", call.ID) + } + if call.FunctionName != "get_weather" { + t.Errorf("functionCall.name 错误: %q", call.FunctionName) + } + + resp := got.Contents[2].Parts[0].FunctionResponse + if resp == nil { + t.Fatal("user tool_result 应转换为 functionResponse") + } + if resp.Name != "get_weather" { + t.Errorf("functionResponse 应按 tool_use_id 关联出函数名, got %q", resp.Name) + } + if m, ok := resp.Response.(map[string]any); !ok || m["result"] != "晴 25 度" { + t.Errorf("functionResponse.response 应为对象 {\"result\": ...}, got %#v", resp.Response) + } +} + +// TestClaudeToGeminiRequest_ToolResultJSONString JSON 字符串形态的工具结果直接解析为对象。 +func TestClaudeToGeminiRequest_ToolResultJSONString(t *testing.T) { + req := &dto.ClaudeRequest{ + Messages: []dto.ClaudeMessage{ + {Role: "assistant", Content: []any{map[string]any{ + "type": "tool_use", "id": "toolu_02", "name": "calc", "input": map[string]any{}, + }}}, + {Role: "user", Content: []any{map[string]any{ + "type": "tool_result", "tool_use_id": "toolu_02", "content": `{"v":25}`, + }}}, + }, + } + got := runClaudeToGeminiRequest(t, req) + + resp := got.Contents[1].Parts[0].FunctionResponse + if resp == nil { + t.Fatal("functionResponse 未生成") + } + if m, ok := resp.Response.(map[string]any); !ok || m["v"] != float64(25) { + t.Errorf("JSON 字符串结果应解析为对象, got %#v", resp.Response) + } +} + +// TestClaudeToGeminiRequest_TopKAndStopSequences top_k 保留,stop_sequences 按 Gemini 上限截断。 +func TestClaudeToGeminiRequest_TopKAndStopSequences(t *testing.T) { + topK := 40 + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + TopK: &topK, + StopSequences: []string{"a", "b", "c", "d", "e", "f"}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "hi"}}, + }) + + if got.GenerationConfig.TopK == nil || *got.GenerationConfig.TopK != 40 { + t.Errorf("topK 应保留(链式无对应字段会丢失), got %+v", got.GenerationConfig.TopK) + } + if len(got.GenerationConfig.StopSequences) != geminiMaxStopSequences { + t.Errorf("stopSequences 应截断为 %d 个, got %d", + geminiMaxStopSequences, len(got.GenerationConfig.StopSequences)) + } +} + +// TestClaudeToGeminiRequest_MediaBlocks base64 走 inlineData,URL 走 fileData。 +func TestClaudeToGeminiRequest_MediaBlocks(t *testing.T) { + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + Messages: []dto.ClaudeMessage{{Role: "user", Content: []any{ + map[string]any{"type": "text", "text": "看图"}, + map[string]any{"type": "image", "source": map[string]any{ + "type": "base64", "media_type": "image/png", "data": "AAAA", + }}, + map[string]any{"type": "image", "source": map[string]any{ + "type": "url", "media_type": "image/jpeg", "url": "https://example.com/a.jpg", + }}, + }}}, + }) + + parts := got.Contents[0].Parts + if len(parts) != 3 { + t.Fatalf("parts 数量应为 3, got %d", len(parts)) + } + if parts[1].InlineData == nil || parts[1].InlineData.MimeType != "image/png" || parts[1].InlineData.Data != "AAAA" { + t.Errorf("base64 图片应转为 inlineData, got %+v", parts[1]) + } + if parts[2].FileData == nil || parts[2].FileData.FileURI != "https://example.com/a.jpg" { + t.Errorf("URL 图片应转为 fileData, got %+v", parts[2]) + } +} + +// TestClaudeToGeminiRequest_TypedBlocks content 已解析为 []dto.ClaudeContentBlock 时同样可用。 +func TestClaudeToGeminiRequest_TypedBlocks(t *testing.T) { + text := "答案" + thinking := "推理" + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "问题"}, + {Role: "assistant", Content: []dto.ClaudeContentBlock{ + {Type: "thinking", Thinking: &thinking, Signature: "sig-typed"}, + {Type: "text", Text: &text}, + }}, + }, + }) + + parts := got.Contents[1].Parts + if len(parts) != 2 { + t.Fatalf("parts 数量应为 2, got %d", len(parts)) + } + if parts[0].ThoughtSignature != "sig-typed" { + t.Errorf("类型化 thinking 块签名未搬运, got %q", parts[0].ThoughtSignature) + } + if parts[1].Text != "答案" { + t.Errorf("类型化 text 块文本错误: %q", parts[1].Text) + } +} + +// TestClaudeToGeminiRequest_EmptyPartsSkipped 无可搬运内容的消息不产出空 parts 的 content。 +func TestClaudeToGeminiRequest_EmptyPartsSkipped(t *testing.T) { + got := runClaudeToGeminiRequest(t, &dto.ClaudeRequest{ + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "redacted_thinking", "data": "加密块"}, + map[string]any{"type": "tool_result", "tool_use_id": "unknown", "content": "x"}, + }}, + }, + }) + if len(got.Contents) != 1 { + t.Errorf("无可搬运内容的消息应整条跳过, contents=%+v", got.Contents) + } +} + +// TestClaudeToGeminiRequest_RejectsNonClaudeRequest 入参类型不符时报错。 +func TestClaudeToGeminiRequest_RejectsNonClaudeRequest(t *testing.T) { + conv := &ClaudeToGeminiRequestConverter{} + if _, err := conv.ConvertRequest(context.Background(), claudeInboundMeta(false), "not a request"); err == nil { + t.Fatal("非 *dto.ClaudeRequest 入参应返回错误") + } +} + +// TestClaudeToGeminiRequest_WebSearchToGoogleSearch 渠道开关控制的 +// web_search → googleSearch 映射(第一档:仅请求侧)。 +func TestClaudeToGeminiRequest_WebSearchToGoogleSearch(t *testing.T) { + webSearchReq := func() *dto.ClaudeRequest { + return &dto.ClaudeRequest{ + Tools: []dto.ClaudeTool{{Name: "web_search", Type: "web_search_20250305"}}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "今天有什么新闻"}}, + } + } + convertWith := func(t *testing.T, req *dto.ClaudeRequest, mapWebSearch bool) *dto.GeminiChatRequest { + t.Helper() + meta := claudeInboundMeta(false) + meta.Options = &convmeta.Options{Gemini: convmeta.GeminiOptions{WebSearchToGoogleSearch: mapWebSearch}} + conv := &ClaudeToGeminiRequestConverter{} + got, err := conv.ConvertRequest(context.Background(), meta, req) + if err != nil { + t.Fatalf("转换失败: %v", err) + } + return got.(*dto.GeminiChatRequest) + } + + t.Run("开关关闭时跳过", func(t *testing.T) { + got := convertWith(t, webSearchReq(), false) + if got.Tools != nil { + t.Errorf("未开启映射时 web_search 应被跳过, got %s", got.Tools) + } + }) + + t.Run("开关开启映射为 googleSearch", func(t *testing.T) { + got := convertWith(t, webSearchReq(), true) + if !strings.Contains(string(got.Tools), `"googleSearch"`) { + t.Errorf("应映射为 googleSearch 工具, got %s", got.Tools) + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal(got.Tools, &entries); err != nil || len(entries) != 1 { + t.Fatalf("tools 应为单元素数组, got %s (err=%v)", got.Tools, err) + } + if got.ToolConfig != nil { + t.Errorf("纯 googleSearch 不应附加 toolConfig, got %+v", got.ToolConfig) + } + }) + + t.Run("与自定义函数混用时保守丢弃搜索", func(t *testing.T) { + req := webSearchReq() + req.Tools = append(req.Tools, dto.ClaudeTool{ + Name: "get_weather", + InputSchema: map[string]any{"type": "object"}, + }) + got := convertWith(t, req, true) + if strings.Contains(string(got.Tools), `"googleSearch"`) { + t.Errorf("混用时应丢弃 googleSearch 保住函数声明, got %s", got.Tools) + } + if !strings.Contains(string(got.Tools), `"functionDeclarations"`) { + t.Errorf("函数声明应保留, got %s", got.Tools) + } + }) +} + +// TestClaudeToGeminiRequest_ConverterMetadata 转换器 ID / 方向 / 质量声明。 +func TestClaudeToGeminiRequest_ConverterMetadata(t *testing.T) { + conv := &ClaudeToGeminiRequestConverter{} + if conv.ID() != "claude_messages_to_gemini_generate_content" { + t.Errorf("ID 错误: %q", conv.ID()) + } + if string(conv.From()) != "claude" || string(conv.To()) != "gemini" { + t.Errorf("方向错误: %s → %s", conv.From(), conv.To()) + } +} + +// TestClaudeToGeminiRequest_PreservesWhatChainLoses 直连相对「Claude→OpenAI→Gemini」 +// 两跳链的保真度增益:精确 thinking budget、thinking 签名、top_k、tool_use.id 四项 +// 均可经直连保留,而链式必然丢失。本用例即直连存在的理由。 +func TestClaudeToGeminiRequest_PreservesWhatChainLoses(t *testing.T) { + ctx := context.Background() + meta := claudeInboundMeta(false) + budget := 5000 + topK := 40 + req := &dto.ClaudeRequest{ + MaxTokens: c2gUintPtr(2048), + TopK: &topK, + Thinking: &dto.ClaudeThinking{Type: "enabled", BudgetTokens: &budget}, + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "问题"}, + {Role: "assistant", Content: []any{ + map[string]any{"type": "thinking", "thinking": "推理", "signature": "sig-1"}, + map[string]any{"type": "tool_use", "id": "toolu_1", "name": "f", "input": map[string]any{}}, + }}, + }, + } + + conv := &ClaudeToGeminiRequestConverter{} + directAny, err := conv.ConvertRequest(ctx, meta, req) + if err != nil { + t.Fatalf("直连转换失败: %v", err) + } + direct := directAny.(*dto.GeminiChatRequest) + + // 两跳链:Claude→OpenAI→Gemini + mid, err := (&oai_chat.ClaudeToOpenAIRequestConverter{}).ConvertRequest(ctx, meta, req) + if err != nil { + t.Fatalf("第一跳转换失败: %v", err) + } + chainedAny, err := (&oai_gemini.OpenAIToGeminiRequestConverter{}).ConvertRequest(ctx, meta, mid) + if err != nil { + t.Fatalf("第二跳转换失败: %v", err) + } + chained := chainedAny.(*dto.GeminiChatRequest) + + // thinking budget:直连 5000,链式经 reasoning_effort 档位展开为 8192 + if b := direct.GenerationConfig.ThinkingConfig.ThinkingBudget; b == nil || *b != 5000 { + t.Errorf("直连应保留精确 budget 5000, got %+v", b) + } + if b := chained.GenerationConfig.ThinkingConfig.ThinkingBudget; b == nil || *b != 8192 { + t.Errorf("链式应放大为 8192(本用例前提), got %+v", b) + } + + // top_k:链式无对应字段 + if direct.GenerationConfig.TopK == nil { + t.Error("直连应保留 topK") + } + if chained.GenerationConfig.TopK != nil { + t.Errorf("链式应丢失 topK(本用例前提), got %+v", chained.GenerationConfig.TopK) + } + + // thinking 签名与 tool_use.id:链式经中枢格式丢弃 + if direct.Contents[1].Parts[0].ThoughtSignature != "sig-1" { + t.Errorf("直连应保留 thinking 签名, got %q", direct.Contents[1].Parts[0].ThoughtSignature) + } + if chained.Contents[1].Parts[0].ThoughtSignature != "" { + t.Errorf("链式应丢失 thinking 签名(本用例前提), got %q", chained.Contents[1].Parts[0].ThoughtSignature) + } + if id := direct.Contents[1].Parts[1].FunctionCall.ID; id != "toolu_1" { + t.Errorf("直连应保留 functionCall.id, got %q", id) + } +} diff --git a/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_response.go b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_response.go new file mode 100644 index 0000000..b3aedfd --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_response.go @@ -0,0 +1,132 @@ +package claude_gemini + +import ( + "context" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// ClaudeToGeminiResponseConverter 将 Claude Messages 非流式响应转换为 Gemini GenerateContent 响应。 +// +// 协议落差(转换时按下列口径处理): +// - Claude 的 output_tokens 含思考 token 且不单独拆分,Gemini 的 candidatesTokenCount +// 语义上不含 thoughts → 无法还原拆分,thoughtsTokenCount 记 0、全部计入 candidates。 +// - redacted_thinking 是 Claude 的加密思考块,Gemini 无对应物 → 丢弃。 +// - thinking.signature 搬到 Gemini 的 thoughtSignature 上(单向出站)。客户端回传的 +// 签名在请求侧 Gemini→OpenAI→Claude 的 chat 中间格式里没有字段承载,故本方向的 +// 签名往返不闭合;这是已知取舍,不是遗漏。 +type ClaudeToGeminiResponseConverter struct{} + +func (c *ClaudeToGeminiResponseConverter) ID() string { + return relayconvert.ResponseConverterClaudeMessagesToGeminiChat +} + +func (c *ClaudeToGeminiResponseConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToGeminiResponseConverter) To() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *ClaudeToGeminiResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 *dto.ClaudeResponse 转换为 *dto.GeminiChatResponse。 +func (c *ClaudeToGeminiResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + claudeResp, ok := response.(*dto.ClaudeResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.ClaudeResponse, got %T", response) + } + return claudeToGeminiResponse(claudeResp, info), nil +} + +// claudeToGeminiResponse 将 Claude 非流式响应转换为 Gemini 响应。 +// 内容块顺序保持 Claude 的原始顺序(模型已按语义排好 thinking / 正文 / 工具调用的先后)。 +func claudeToGeminiResponse(claudeResp *dto.ClaudeResponse, info convmeta.Meta) *dto.GeminiChatResponse { + parts := make([]dto.GeminiPart, 0, len(claudeResp.Content)) + + for i := range claudeResp.Content { + block := &claudeResp.Content[i] + switch block.Type { + case "text": + if block.Text != nil && *block.Text != "" { + parts = append(parts, dto.GeminiPart{Text: *block.Text}) + } + case "thinking": + if block.Thinking != nil && *block.Thinking != "" { + parts = append(parts, dto.GeminiPart{ + Text: *block.Thinking, + Thought: boolPtr(true), + ThoughtSignature: block.Signature, + }) + } + case "redacted_thinking": + // Claude 的加密思考块,Gemini 无对应物,丢弃 + case "tool_use": + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + // Gemini 的 functionCall 支持可选 id,直接搬运 Claude 的 tool_use.id + ID: block.ID, + FunctionName: block.Name, + Arguments: claudeToolInput(block.Input), + }, + }) + } + } + + modelName := claudeResp.Model + if modelName == "" || isModelMapped(info) { + modelName = originModelName(info) + } + + resp := &dto.GeminiChatResponse{ + ModelName: modelName, + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{Role: "model", Parts: parts}, + FinishReason: claudeStopReasonToGemini(claudeResp.StopReason), + }}, + } + if um := geminiUsageFromClaude(claudeResp.Usage); um != nil { + resp.UsageMetadata = um + } + return resp +} + +// geminiUsageFromClaude 按 Gemini 协议口径换算用量(客户端可见值,非计费值)。 +// - Gemini 的 promptTokenCount 含缓存,Claude 的 input_tokens 与两项 cache 并列,需相加 +// - Gemini 的 candidatesTokenCount 语义上不含 thoughts,但 Claude 的 output_tokens 含思考 +// 且不单独拆分 → 无法还原,全部计入 candidates,thoughtsTokenCount 保持 0 +func geminiUsageFromClaude(u *dto.ClaudeUsage) *dto.GeminiUsageMetadata { + if u == nil { + return nil + } + promptTotal := u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens + if promptTotal == 0 && u.OutputTokens == 0 { + return nil + } + return &dto.GeminiUsageMetadata{ + PromptTokenCount: promptTotal, + CandidatesTokenCount: u.OutputTokens, + TotalTokenCount: promptTotal + u.OutputTokens, + CachedContentTokenCount: u.CacheReadInputTokens, + } +} + +// claudeToolInput 归一化 tool_use.input:Gemini 的 functionCall.args 期望对象, +// Claude 允许 null,此时回退为空对象。 +func claudeToolInput(input any) any { + if input == nil { + return map[string]any{} + } + return input +} diff --git a/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_stream.go b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_stream.go new file mode 100644 index 0000000..bfd513f --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_stream.go @@ -0,0 +1,252 @@ +package claude_gemini + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// geminiToolCallState 流式聚合中的工具调用(Claude tool_use 块)。 +// Claude 的参数按 input_json_delta 分片下发,而 Gemini 的 functionCall.args 必须是 +// 完整对象,因此必须缓冲到 content_block_stop 才能发出。 +type geminiToolCallState struct { + id string + name string + args strings.Builder +} + +// ClaudeToGeminiStreamConverter 将 Claude SSE 流转换为 Gemini SSE 流。 +// 事件形态与 Gemini 出站保持一致:逐 chunk 输出纯 data 帧(StreamEvent.Event 为空), +// 末尾补 finishReason + usageMetadata 的收尾 chunk(同时携带计费用量); +// [DONE] 帧由宿主桥接层写出。 +type ClaudeToGeminiStreamConverter struct{} + +func (c *ClaudeToGeminiStreamConverter) ID() string { + return relayconvert.ResponseConverterClaudeMessagesToGeminiChatStream +} + +func (c *ClaudeToGeminiStreamConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToGeminiStreamConverter) To() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *ClaudeToGeminiStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Claude SSE 事件流转换为 Gemini data 帧序列。 +// reader 提供上游 Claude 的原生 SSE;每个「写事件」以 chunkWriter(StreamEvent) 交宿主帧化。 +func (c *ClaudeToGeminiStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + modelName := originModelName(info) + + var ( + usage dto.ClaudeUsage + stopReason string + currentTool *geminiToolCallState + ) + + // emitParts 发出一个仅含 content parts 的 Gemini chunk + emitParts := func(parts []dto.GeminiPart) error { + if len(parts) == 0 { + return nil + } + chunk := &dto.GeminiChatResponse{ + ModelName: modelName, + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{Role: "model", Parts: parts}, + }}, + } + return chunkWriter(&relayconvert.StreamEvent{Data: chunk}) + } + + // flushTool 把缓冲的工具调用作为 functionCall part 发出 + flushTool := func() error { + if currentTool == nil { + return nil + } + var args any + if raw := currentTool.args.String(); raw != "" { + if err := json.Unmarshal([]byte(raw), &args); err != nil { + // 参数分片拼接后仍非法:降级为空对象,保留函数名让客户端可见调用意图 + args = map[string]any{} + } + } else { + args = map[string]any{} + } + err := emitParts([]dto.GeminiPart{{ + FunctionCall: &dto.GeminiFunctionCall{ + // Gemini 的 functionCall 支持可选 id,直接搬运 Claude 的 tool_use.id + ID: currentTool.id, + FunctionName: currentTool.name, + Arguments: args, + }, + }}) + currentTool = nil + return err + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + // 流中断:计费兜底(输出估算、输入补齐)由宿主桥接层处理 + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + + var event dto.ClaudeResponse + if err := json.Unmarshal([]byte(data), &event); err != nil { + // JSON 解析失败:静默跳过(允许部分格式异常) + continue + } + + switch event.Type { + case "message_start": + if event.Message != nil { + // 模型映射时不回填上游模型名,避免泄漏给客户端 + if event.Message.Model != "" && !isModelMapped(info) { + modelName = event.Message.Model + } + if event.Message.Usage != nil { + usage = *event.Message.Usage + } + } + + case "content_block_start": + if event.ContentBlock == nil { + continue + } + if event.ContentBlock.Type == "tool_use" { + // 上一个工具块理论上已在 content_block_stop 冲刷,这里兜底防止串块 + if err := flushTool(); err != nil { + return err + } + currentTool = &geminiToolCallState{ + id: event.ContentBlock.ID, + name: event.ContentBlock.Name, + } + } + + case "content_block_delta": + if event.Delta == nil { + continue + } + switch event.Delta.Type { + case "text_delta": + if event.Delta.Text != nil && *event.Delta.Text != "" { + if err := emitParts([]dto.GeminiPart{{Text: *event.Delta.Text}}); err != nil { + return err + } + } + case "thinking_delta": + if event.Delta.Thinking != nil && *event.Delta.Thinking != "" { + if err := emitParts([]dto.GeminiPart{{Text: *event.Delta.Thinking, Thought: boolPtr(true)}}); err != nil { + return err + } + } + case "signature_delta": + if event.Delta.Signature != "" { + // 思考签名单独成 part(无文本),与 Gemini 把签名挂在 thought part 上的形态一致 + if err := emitParts([]dto.GeminiPart{{Thought: boolPtr(true), ThoughtSignature: event.Delta.Signature}}); err != nil { + return err + } + } + case "input_json_delta": + if currentTool != nil && event.Delta.PartialJSON != nil { + currentTool.args.WriteString(*event.Delta.PartialJSON) + } + } + + case "content_block_stop": + if err := flushTool(); err != nil { + return err + } + + case "message_delta": + if event.Delta != nil && event.Delta.StopReason != nil { + stopReason = *event.Delta.StopReason + } + if event.Usage != nil { + if event.Usage.InputTokens > 0 { + usage.InputTokens = event.Usage.InputTokens + } + usage.OutputTokens = event.Usage.OutputTokens + if event.Usage.CacheReadInputTokens > 0 { + usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens + } + if event.Usage.CacheCreationInputTokens > 0 { + usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens + } + if event.Usage.CacheCreation != nil { + usage.CacheCreation = event.Usage.CacheCreation + } + } + + case "error": + // 与旧桥一致:仅记录、不中断——收尾 chunk 仍会发出,避免客户端拿不到 finishReason。 + // 错误上报(StreamStatus)属宿主职责,本层不承载。 + + case "message_stop": + // 收尾在循环外统一处理,保证异常结束路径也走同一套 + } + } + + // 未闭合的工具块(上游异常断流):兜底冲刷,避免整个调用丢失 + if err := flushTool(); err != nil { + return err + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + // 读流出错:已累计的 usage 无法随错误返回(StreamEvent 只随帧携带),由宿主按估算兜底 + return fmt.Errorf("stream scanner error: %w", err) + } + + // 收尾 chunk:finishReason + usageMetadata(与 Gemini 出站的既有形态一致), + // 同时携带 Claude 计费口径的用量供宿主捕获(input 不含缓存、cache_creation 独立计价) + finalChunk := &dto.GeminiChatResponse{ + ModelName: modelName, + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{Role: "model"}, + FinishReason: claudeStopReasonToGemini(stopReason), + }}, + } + if um := geminiUsageFromClaude(&usage); um != nil { + finalChunk.UsageMetadata = um + } + return chunkWriter(&relayconvert.StreamEvent{ + Data: finalChunk, + Usage: claudeBillingUsage(&usage), + }) +} diff --git a/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_test.go b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_test.go new file mode 100644 index 0000000..09f2d98 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/claude_to_gemini_test.go @@ -0,0 +1,384 @@ +package claude_gemini + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// geminiInboundMeta 构造 Gemini 入站 + Claude 上游渠道的 Meta +func geminiInboundMeta(isStream bool) *convmeta.Values { + return &convmeta.Values{ + OriginModelName: "claude-sonnet-5", + UpstreamModelName: "claude-sonnet-5", + ChannelMetaAttached: true, + IsStream: isStream, + } +} + +// runClaudeToGeminiStream 跑流式转换并收集 StreamEvent 序列 +func runClaudeToGeminiStream(t *testing.T, meta convmeta.Meta, upstream string) ([]*relayconvert.StreamEvent, error) { + t.Helper() + conv := &ClaudeToGeminiStreamConverter{} + var events []*relayconvert.StreamEvent + err := conv.ConvertStreamResponse(context.Background(), meta, strings.NewReader(upstream), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("chunk 类型应为 *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +// geminiChunksOf 提取事件序列中的 Gemini chunk(校验帧形态:Gemini 客户端为纯 data 帧) +func geminiChunksOf(t *testing.T, events []*relayconvert.StreamEvent) []*dto.GeminiChatResponse { + t.Helper() + chunks := make([]*dto.GeminiChatResponse, 0, len(events)) + for _, ev := range events { + if ev.Event != "" { + t.Fatalf("Gemini 客户端应为纯 data 帧(Event 为空), got %q", ev.Event) + } + chunk, ok := ev.Data.(*dto.GeminiChatResponse) + if !ok { + t.Fatalf("data 类型应为 *dto.GeminiChatResponse, got %T", ev.Data) + } + chunks = append(chunks, chunk) + } + return chunks +} + +// collectParts 把所有 chunk 的 parts 按顺序摊平 +func collectParts(chunks []*dto.GeminiChatResponse) []dto.GeminiPart { + var parts []dto.GeminiPart + for _, c := range chunks { + for _, cand := range c.Candidates { + if cand.Content != nil { + parts = append(parts, cand.Content.Parts...) + } + } + } + return parts +} + +// ===== 非流式 ===== + +// TestClaudeToGeminiResponse_BlockMapping text/thinking/tool_use 按原始顺序映射为 parts, +// redacted_thinking 无对应物被丢弃。 +func TestClaudeToGeminiResponse_BlockMapping(t *testing.T) { + thinking := "让我想想" + text := "答案是 42" + claudeResp := &dto.ClaudeResponse{ + ID: "msg_1", + Type: "message", + Role: "assistant", + Model: "claude-sonnet-5", + Content: []dto.ClaudeContentBlock{ + {Type: "thinking", Thinking: &thinking, Signature: "sig-abc"}, + {Type: "redacted_thinking"}, + {Type: "text", Text: &text}, + {Type: "tool_use", ID: "toolu_9", Name: "get_weather", Input: map[string]any{"city": "北京"}}, + }, + StopReason: claudeToolUse, + Usage: &dto.ClaudeUsage{ + InputTokens: 40, + CacheReadInputTokens: 10, + CacheCreationInputTokens: 5, + OutputTokens: 20, + }, + } + + conv := &ClaudeToGeminiResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), geminiInboundMeta(false), claudeResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got, ok := res.(*dto.GeminiChatResponse) + if !ok { + t.Fatalf("返回类型应为 *dto.GeminiChatResponse, got %T", res) + } + + if len(got.Candidates) != 1 || got.Candidates[0].Content == nil { + t.Fatalf("candidates 结构不对: %+v", got.Candidates) + } + if got.Candidates[0].Content.Role != "model" { + t.Errorf("role = %q, want model", got.Candidates[0].Content.Role) + } + parts := got.Candidates[0].Content.Parts + if len(parts) != 3 { + t.Fatalf("parts 数 = %d, want 3(redacted_thinking 应被丢弃): %+v", len(parts), parts) + } + + // thinking → thought part + thoughtSignature + if parts[0].Text != thinking { + t.Errorf("首个 part 应为思考文本: %+v", parts[0]) + } + if parts[0].Thought == nil || !*parts[0].Thought { + t.Errorf("思考 part 必须带 thought=true: %+v", parts[0]) + } + if parts[0].ThoughtSignature != "sig-abc" { + t.Errorf("signature 未搬运到 thoughtSignature: %q", parts[0].ThoughtSignature) + } + + // text + if parts[1].Text != text || parts[1].Thought != nil { + t.Errorf("次个 part 应为普通文本: %+v", parts[1]) + } + + // tool_use → functionCall(id 可搬运,Gemini 的 functionCall 支持可选 id) + if parts[2].FunctionCall == nil { + t.Fatalf("第三个 part 应为 functionCall: %+v", parts[2]) + } + fc := parts[2].FunctionCall + if fc.FunctionName != "get_weather" || fc.ID != "toolu_9" { + t.Errorf("functionCall 不对: %+v", fc) + } + + if got.Candidates[0].FinishReason != geminiSTOP { + t.Errorf("finishReason = %q, want STOP", got.Candidates[0].FinishReason) + } + + if got.ModelName != "claude-sonnet-5" { + t.Errorf("modelName = %q, want claude-sonnet-5", got.ModelName) + } + + // Gemini 口径:prompt 含全部缓存 + if got.UsageMetadata.PromptTokenCount != 55 { + t.Errorf("promptTokenCount = %d, want 55(40+10+5)", got.UsageMetadata.PromptTokenCount) + } + if got.UsageMetadata.CachedContentTokenCount != 10 { + t.Errorf("cachedContentTokenCount = %d, want 10", got.UsageMetadata.CachedContentTokenCount) + } + if got.UsageMetadata.CandidatesTokenCount != 20 || got.UsageMetadata.ThoughtsTokenCount != 0 { + t.Errorf("Claude 不拆分思考 token,应全部计入 candidates: %+v", got.UsageMetadata) + } + if got.UsageMetadata.TotalTokenCount != 75 { + t.Errorf("totalTokenCount = %d, want 75", got.UsageMetadata.TotalTokenCount) + } +} + +// TestClaudeToGeminiResponse_NullToolInput Claude 允许 input 为 null, +// Gemini 的 functionCall.args 期望对象。 +func TestClaudeToGeminiResponse_NullToolInput(t *testing.T) { + claudeResp := &dto.ClaudeResponse{ + Content: []dto.ClaudeContentBlock{{Type: "tool_use", ID: "t1", Name: "ping"}}, + StopReason: claudeToolUse, + } + + conv := &ClaudeToGeminiResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), geminiInboundMeta(false), claudeResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got := res.(*dto.GeminiChatResponse) + + fc := got.Candidates[0].Content.Parts[0].FunctionCall + if fc.Arguments == nil { + t.Fatal("空 input 应回退为空对象,got nil") + } + if _, ok := fc.Arguments.(map[string]any); !ok { + t.Errorf("args 应为对象: %T", fc.Arguments) + } +} + +// TestClaudeToGeminiResponse_WrongType 类型不匹配时报错而非 panic +func TestClaudeToGeminiResponse_WrongType(t *testing.T) { + conv := &ClaudeToGeminiResponseConverter{} + if _, err := conv.ConvertResponse(context.Background(), geminiInboundMeta(false), "not-a-response"); err == nil { + t.Fatal("非 *dto.ClaudeResponse 输入应返回错误") + } +} + +// TestGeminiUsageFromClaude_NilAndEmpty 无用量信息时不应造出全零的 usageMetadata +func TestGeminiUsageFromClaude_NilAndEmpty(t *testing.T) { + if geminiUsageFromClaude(nil) != nil { + t.Error("nil usage 应返回 nil") + } + if geminiUsageFromClaude(&dto.ClaudeUsage{}) != nil { + t.Error("全零 usage 应返回 nil,避免向客户端报 0 token") + } +} + +// ===== 流式 ===== + +// TestClaudeToGeminiStream_TextAndThinking 思考与正文分别映射为 thought part / 普通 part, +// 收尾 chunk 带 finishReason 与 usageMetadata,并随帧携带 Claude 计费口径的用量。 +func TestClaudeToGeminiStream_TextAndThinking(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":40,"output_tokens":0,"cache_read_input_tokens":10}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"想一下"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-xyz"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + ``, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"你好"}}`, + ``, + `data: {"type":"content_block_stop","index":1}`, + ``, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":40,"output_tokens":20,"cache_read_input_tokens":10}}`, + ``, + `data: {"type":"message_stop"}`, + ``, + ``, + }, "\n") + + events, err := runClaudeToGeminiStream(t, geminiInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + chunks := geminiChunksOf(t, events) + if len(chunks) < 2 { + t.Fatalf("chunk 数过少: %d", len(chunks)) + } + + parts := collectParts(chunks) + if len(parts) != 3 { + t.Fatalf("parts 数 = %d, want 3(thinking / signature / text): %+v", len(parts), parts) + } + if parts[0].Text != "想一下" || parts[0].Thought == nil || !*parts[0].Thought { + t.Errorf("思考 part 不对: %+v", parts[0]) + } + if parts[1].ThoughtSignature != "sig-xyz" { + t.Errorf("signature part 不对: %+v", parts[1]) + } + if parts[2].Text != "你好" || parts[2].Thought != nil { + t.Errorf("文本 part 不对: %+v", parts[2]) + } + + final := chunks[len(chunks)-1] + if final.Candidates[0].FinishReason != geminiSTOP { + t.Errorf("收尾 finishReason = %q, want STOP", final.Candidates[0].FinishReason) + } + if final.UsageMetadata == nil || final.UsageMetadata.PromptTokenCount != 50 { + t.Errorf("收尾 usageMetadata 不对(应为 40+10 缓存): %+v", final.UsageMetadata) + } + if final.ModelName != "claude-sonnet-5" { + t.Errorf("modelName = %q, want claude-sonnet-5", final.ModelName) + } + + // 计费口径:Claude 的 input_tokens 不含缓存,随收尾帧携带 + lastEvent := events[len(events)-1] + if lastEvent.Usage == nil { + t.Fatal("收尾帧必须携带计费用量") + } + if lastEvent.Usage.PromptTokens != 40 || lastEvent.Usage.CompletionTokens != 20 { + t.Errorf("计费用量 = %d/%d, want 40/20", lastEvent.Usage.PromptTokens, lastEvent.Usage.CompletionTokens) + } + if lastEvent.Usage.PromptTokensDetails == nil || lastEvent.Usage.PromptTokensDetails.CachedTokens != 10 { + t.Errorf("计费缓存明细不对: %+v", lastEvent.Usage.PromptTokensDetails) + } + // 前置内容帧不带用量 + for _, ev := range events[:len(events)-1] { + if ev.Usage != nil { + t.Errorf("非收尾帧不应携带用量: %+v", ev) + } + } +} + +// TestClaudeToGeminiStream_ToolCallBuffered Claude 的 input_json_delta 分片必须 +// 缓冲拼接成完整对象后才能作为 functionCall.args 发出(Gemini 不接受分片)。 +func TestClaudeToGeminiStream_ToolCallBuffered(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"usage":{"input_tokens":10,"output_tokens":0}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_7","name":"get_weather","input":{}}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"北京\"}"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":10,"output_tokens":6}}`, + ``, + `data: {"type":"message_stop"}`, + ``, + ``, + }, "\n") + + events, err := runClaudeToGeminiStream(t, geminiInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + parts := collectParts(geminiChunksOf(t, events)) + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("应恰好产出一个 functionCall part: %+v", parts) + } + + fc := parts[0].FunctionCall + if fc.FunctionName != "get_weather" || fc.ID != "toolu_7" { + t.Errorf("functionCall 元信息不对: %+v", fc) + } + args, ok := fc.Arguments.(map[string]any) + if !ok { + t.Fatalf("args 应为拼接后的对象, got %T: %v", fc.Arguments, fc.Arguments) + } + if args["city"] != "北京" { + t.Errorf("分片参数拼接错误: %+v", args) + } +} + +// TestClaudeToGeminiStream_MalformedToolArgs 分片拼接后仍非法时降级为空对象, +// 保留函数名而不是整段丢弃或输出非法 JSON。 +func TestClaudeToGeminiStream_MalformedToolArgs(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"broken"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + ``, + }, "\n") + + events, err := runClaudeToGeminiStream(t, geminiInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + parts := collectParts(geminiChunksOf(t, events)) + if len(parts) != 1 || parts[0].FunctionCall == nil { + t.Fatalf("应产出 functionCall part: %+v", parts) + } + if parts[0].FunctionCall.FunctionName != "broken" { + t.Errorf("函数名应保留: %+v", parts[0].FunctionCall) + } + if _, ok := parts[0].FunctionCall.Arguments.(map[string]any); !ok { + t.Errorf("非法参数应降级为空对象: %v", parts[0].FunctionCall.Arguments) + } +} + +// TestClaudeToGeminiStream_UnclosedToolFlushed 上游异常断流、工具块未收到 +// content_block_stop 时兜底冲刷,避免整个调用丢失。 +func TestClaudeToGeminiStream_UnclosedToolFlushed(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"half"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a\":1}"}}`, + ``, + ``, + }, "\n") + + events, err := runClaudeToGeminiStream(t, geminiInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + parts := collectParts(geminiChunksOf(t, events)) + if len(parts) != 1 || parts[0].FunctionCall == nil || parts[0].FunctionCall.FunctionName != "half" { + t.Fatalf("未闭合的工具块应被兜底冲刷: %+v", parts) + } +} diff --git a/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_response.go b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_response.go new file mode 100644 index 0000000..ce708ed --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_response.go @@ -0,0 +1,227 @@ +package claude_gemini + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// GeminiToClaudeResponseConverter 将 Gemini GenerateContent 非流式响应转换为 Claude Messages 响应。 +// +// 协议落差(Gemini 侧无对应物,转换时按下列口径处理): +// - functionCall 没有调用 ID,Claude 的 tool_use 必须带 id → 本层合成, +// 客户端回传的 tool_result.tool_use_id 在请求侧按函数名还原。 +// - inlineData / executableCode / codeExecutionResult / fileData 在 Claude +// 的 assistant 内容块里没有对应类型 → 按既有 Gemini→OpenAI 口径渲染为文本, +// 信息保留而非丢弃。 +// - thoughtSignature 只在 thinking 块上有落点(Claude 的 signature 字段)。 +// 挂在 functionCall part 上的签名(Gemini 3 函数调用)无处安放,只能丢弃; +// 且客户端回传的 signature 在请求侧 Claude→OpenAI→Gemini 的 chat 中间格式里 +// 同样没有字段承载,故本方向的签名往返目前不闭合。 +type GeminiToClaudeResponseConverter struct{} + +func (c *GeminiToClaudeResponseConverter) ID() string { + return relayconvert.ResponseConverterGeminiChatToClaudeMessages +} + +func (c *GeminiToClaudeResponseConverter) From() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *GeminiToClaudeResponseConverter) To() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *GeminiToClaudeResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 *dto.GeminiChatResponse 转换为 *dto.ClaudeResponse。 +// promptFeedback.blockReason 命中(Gemini 安全过滤)时返回错误,与旧桥的请求错误分类一致。 +func (c *GeminiToClaudeResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + geminiResp, ok := response.(*dto.GeminiChatResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.GeminiChatResponse, got %T", response) + } + + // 检查 promptFeedback.blockReason(Gemini 安全过滤) + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) + } + + return geminiToClaudeResponse(geminiResp, info), nil +} + +// geminiToClaudeResponse 将 Gemini 非流式响应转换为 Claude Messages 响应。 +// 内容块顺序保持 Gemini parts 的原始顺序(模型已按语义排好,重排会改变思考与正文的先后)。 +func geminiToClaudeResponse(geminiResp *dto.GeminiChatResponse, info convmeta.Meta) *dto.ClaudeResponse { + // 消息/工具 ID 基准:宿主 RequestID 未随 Meta 下沉,以纳秒时间戳保证单响应内唯一 + idBase := fmt.Sprintf("%d", time.Now().UnixNano()) + + content := make([]dto.ClaudeContentBlock, 0) + var ( + finishReason string + hasToolUse bool + toolIdx int + citations *shared.GroundingCitations + answerText strings.Builder + ) + + if len(geminiResp.Candidates) > 0 { + candidate := geminiResp.Candidates[0] + finishReason = candidate.FinishReason + // 服务端搜索证据:上游真的去搜了,来源必须还原成 Claude 的搜索工具块, + // 否则客户端只拿到一段被搜索增强的纯文本,看不到来源、无法核查 + citations = shared.ParseGeminiGrounding(candidate.GroundingMetadata) + + if candidate.Content != nil { + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" { + if isThought { + content = append(content, dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(part.Text), + Signature: part.ThoughtSignature, + }) + } else { + answerText.WriteString(part.Text) + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(part.Text), + }) + } + } + + if part.FunctionCall != nil { + content = append(content, dto.ClaudeContentBlock{ + Type: "tool_use", + ID: claudeToolUseID(idBase, toolIdx), + Name: part.FunctionCall.FunctionName, + Input: geminiFunctionArgs(part.FunctionCall), + }) + toolIdx++ + hasToolUse = true + } + + if fallback := geminiPartFallbackText(part); fallback != "" { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(fallback), + }) + } + } + } + } + + // 搜索块排在正文之前:Claude 的语义是「先发起搜索、拿到结果,再据此作答」 + if !citations.IsEmpty() { + citations.AlignSupports(answerText.String()) + content = append(citations.ToClaudeSearchBlocks(idBase), content...) + } + + // Claude 协议要求 content 非空 + if len(content) == 0 { + content = append(content, dto.ClaudeContentBlock{Type: "text", Text: strPtr("")}) + } + + stopReason := geminiFinishReasonToClaude(finishReason) + if hasToolUse { + // Gemini 带 functionCall 时 finishReason 仍是 STOP,Claude 协议要求 tool_use + stopReason = claudeToolUse + } + + modelName := geminiResp.ModelName + if modelName == "" || isModelMapped(info) { + modelName = originModelName(info) + } + + usage := claudeUsageFromGemini(geminiResp.UsageMetadata) + // 搜索次数透出到 usage:Google 在 token 之外按搜索次数单独计价, + // 不透出则计费层无从得知本次回答发起过几次搜索(定价挂接由宿主计费层完成) + if n := citations.SearchRequestCount(); n > 0 { + usage.ServerToolUse = &dto.ClaudeServerToolUsage{WebSearchRequests: n} + } + + return &dto.ClaudeResponse{ + ID: fmt.Sprintf("msg_%s", idBase), + Type: "message", + Role: "assistant", + Content: content, + StopReason: stopReason, + StopSequence: nil, + Model: modelName, + Usage: usage, + } +} + +// claudeUsageFromGemini 按 Claude 协议口径换算用量(客户端可见值,非计费值)。 +// - Claude 的 input_tokens 不含缓存命中,Gemini 的 promptTokenCount 已含 cachedContentTokenCount,需扣减 +// - Claude 的 output_tokens 含思考,Gemini 的 candidatesTokenCount 不含 thoughtsTokenCount,需合计 +func claudeUsageFromGemini(um *dto.GeminiUsageMetadata) *dto.ClaudeUsage { + if um == nil { + return &dto.ClaudeUsage{} + } + return &dto.ClaudeUsage{ + InputTokens: claudeInputTokens(um), + OutputTokens: um.CandidatesTokenCount + um.ThoughtsTokenCount, + CacheReadInputTokens: um.CachedContentTokenCount, + } +} + +// claudeInputTokens 计算 Claude 口径的 input_tokens(扣除缓存命中部分) +func claudeInputTokens(um *dto.GeminiUsageMetadata) int { + if um == nil { + return 0 + } + input := um.PromptTokenCount - um.CachedContentTokenCount + if input < 0 { + return 0 + } + return input +} + +// geminiFunctionArgs 提取 functionCall 参数,nil 时回退为空对象 +// (Claude 的 tool_use.input 不允许为 null) +func geminiFunctionArgs(fc *dto.GeminiFunctionCall) any { + if fc == nil || fc.Arguments == nil { + return map[string]any{} + } + return fc.Arguments +} + +// claudeToolUseID 为 Gemini functionCall 合成 Claude tool_use ID。 +// Gemini 协议不带调用 ID,客户端回传的 tool_result.tool_use_id 在请求侧 +// 按函数名还原,故此处只需在单次响应内唯一且可被客户端原样回传。 +func claudeToolUseID(idBase string, idx int) string { + return fmt.Sprintf("toolu_%s_%d", idBase, idx) +} + +// geminiPartFallbackText 将 Claude 无对应内容块类型的 Gemini part 渲染为文本。 +// 渲染口径与 Gemini→OpenAI 出站一致。 +func geminiPartFallbackText(part *dto.GeminiPart) string { + switch { + case part.InlineData != nil: + return fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data) + case part.ExecutableCode != nil: + return fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) + case part.CodeExecutionResult != nil: + return fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) + case part.FileData != nil: + return fmt.Sprintf("[file](%s)", part.FileData.FileURI) + } + return "" +} diff --git a/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_stream.go b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_stream.go new file mode 100644 index 0000000..8d8bcaf --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_stream.go @@ -0,0 +1,398 @@ +package claude_gemini + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// GeminiToClaudeStreamConverter 将 Gemini SSE 流转换为 Claude Messages 事件流。 +// 事件序列与 Claude 出站保持一致(message_start → content_block_* → message_delta → message_stop), +// 每个事件以 StreamEvent{Event: 事件名} 输出,由宿主桥接层写为事件帧。 +type GeminiToClaudeStreamConverter struct{} + +func (c *GeminiToClaudeStreamConverter) ID() string { + return relayconvert.ResponseConverterGeminiChatToClaudeMessagesStream +} + +func (c *GeminiToClaudeStreamConverter) From() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *GeminiToClaudeStreamConverter) To() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *GeminiToClaudeStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Gemini SSE 流转换为 Claude 事件帧序列。 +// reader 提供上游 Gemini 的原生 SSE(Code Assist 包装的解包在宿主侧完成)。 +func (c *GeminiToClaudeStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + // 消息/工具 ID 基准:宿主 RequestID 未随 Meta 下沉,以纳秒时间戳保证单响应内唯一 + idBase := fmt.Sprintf("%d", time.Now().UnixNano()) + msgID := fmt.Sprintf("msg_%s", idBase) + modelName := originModelName(info) + + var ( + totalUsage dto.GeminiUsageMetadata + startSent bool + finishReason string + hasToolUse bool + contentIndex int + currentBlockType string + toolIdx int + citations *shared.GroundingCitations + answerText strings.Builder + ) + + // emitEvent 发出一个 Claude 格式事件帧 + emitEvent := func(eventType string, data *dto.ClaudeResponse, usage *dto.UsageWithDetails) error { + return chunkWriter(&relayconvert.StreamEvent{Event: eventType, Data: data, Usage: usage}) + } + + // closeBlock 关闭当前 content block 并推进索引 + closeBlock := func() error { + if currentBlockType == "" { + return nil + } + if err := emitEvent("content_block_stop", &dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + }, nil); err != nil { + return err + } + contentIndex++ + currentBlockType = "" + return nil + } + + // openTextLikeBlock 开启 text/thinking 块;同类型连续增量复用当前块 + openTextLikeBlock := func(blockType string, block *dto.ClaudeContentBlock) error { + if currentBlockType == blockType { + return nil + } + if err := closeBlock(); err != nil { + return err + } + if err := emitEvent("content_block_start", &dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: block, + }, nil); err != nil { + return err + } + currentBlockType = blockType + return nil + } + + // emitSearchBlocks 在流末尾补发服务端搜索证据块(server_tool_use + web_search_tool_result)。 + // + // 位置只能在末尾:Gemini 的 groundingMetadata 随末帧(或靠后的帧)到达,而此时正文 + // 增量早已推给客户端。要还原成 Claude 那样「搜索在前、回答在后」的顺序就得缓冲整段正文, + // 那会毁掉流式的首字延迟——宁可顺序不同,也不牺牲流式体验;证据本身不丢即达成守恒。 + emitSearchBlocks := func() error { + if citations.IsEmpty() { + return nil + } + citations.AlignSupports(answerText.String()) + blocks := citations.ToClaudeSearchBlocks(idBase) + for i := range blocks { + block := blocks[i] + // server_tool_use 的 input 按协议经 input_json_delta 增量下发: + // 只放在 content_block_start 里的话,只累加增量的 SDK 会拿到空 input + var inputJSON string + if block.Type == "server_tool_use" && block.Input != nil { + raw, err := json.Marshal(block.Input) + if err != nil { + return err + } + inputJSON = string(raw) + block.Input = map[string]any{} + } + + if err := emitEvent("content_block_start", &dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &block, + }, nil); err != nil { + return err + } + if inputJSON != "" { + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "input_json_delta", PartialJSON: strPtr(inputJSON)}, + }, nil); err != nil { + return err + } + } + if err := emitEvent("content_block_stop", &dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + }, nil); err != nil { + return err + } + contentIndex++ + } + return nil + } + + // emitFinal 发送 message_delta(stop_reason + 最终用量)与 message_stop。 + // attachUsage 控制是否随 message_delta 帧携带计费用量(安全过滤路径与旧桥一致不携带)。 + emitFinal := func(attachUsage bool) error { + if err := closeBlock(); err != nil { + return err + } + if err := emitSearchBlocks(); err != nil { + return err + } + + stopReason := geminiFinishReasonToClaude(finishReason) + if hasToolUse { + // Gemini 带 functionCall 时 finishReason 仍是 STOP,Claude 协议要求 tool_use, + // 否则客户端不会进入工具调用回合 + stopReason = claudeToolUse + } + + var billing *dto.UsageWithDetails + if attachUsage { + billing = geminiBillingUsage(&totalUsage) + } + if err := emitEvent("message_delta", &dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeDelta{StopReason: strPtr(stopReason)}, + Usage: claudeUsageFromGemini(&totalUsage), + }, billing); err != nil { + return err + } + return emitEvent("message_stop", &dto.ClaudeResponse{Type: "message_stop"}, nil) + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + // 流中断:计费兜底(Gemini 每 chunk 携带累计 usage + 请求侧估算)由宿主桥接层处理 + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal([]byte(data), &geminiResp); err != nil { + continue + } + + if geminiResp.UsageMetadata != nil { + totalUsage = *geminiResp.UsageMetadata + } + if geminiResp.ModelName != "" { + modelName = geminiResp.ModelName + } + + // 检查 promptFeedback.blockReason(流式安全过滤) + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + // 事件流已开场,必须补齐 Claude 的收尾事件,否则客户端挂起等 message_stop + finishReason = "SAFETY" + if err := emitFinal(false); err != nil { + return err + } + return fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) + } + + // message_start:首个有效 chunk 时发送。Gemini 的 usageMetadata 逐块累计, + // 首块的 promptTokenCount 已可用,直接带上 + if !startSent { + if err := emitEvent("message_start", &dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMessageInfo{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []dto.ClaudeContentBlock{}, + Model: modelName, + StopReason: nil, + StopSequence: nil, + Usage: &dto.ClaudeUsage{ + InputTokens: claudeInputTokens(&totalUsage), + OutputTokens: 0, + }, + }, + }, nil); err != nil { + return err + } + startSent = true + } + + for _, candidate := range geminiResp.Candidates { + if candidate.FinishReason != "" { + finishReason = candidate.FinishReason + } + // grounding 随末帧(或靠后的帧)到达,先捕获、流末尾统一补发证据块 + if c := shared.ParseGeminiGrounding(candidate.GroundingMetadata); !c.IsEmpty() { + citations = c + } + if candidate.Content == nil { + continue + } + + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + // 文本 / 思考增量 + if part.Text != "" { + if isThought { + if err := openTextLikeBlock("thinking", &dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(""), + }); err != nil { + return err + } + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "thinking_delta", Thinking: strPtr(part.Text)}, + }, nil); err != nil { + return err + } + } else { + if err := openTextLikeBlock("text", &dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(""), + }); err != nil { + return err + } + answerText.WriteString(part.Text) + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "text_delta", Text: strPtr(part.Text)}, + }, nil); err != nil { + return err + } + } + } + + // thoughtSignature 与 Claude 的 thinking.signature 同为对客户端不透明的载体, + // 原样搬运;仅在 thinking 块内有意义 + if part.ThoughtSignature != "" && currentBlockType == "thinking" { + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "signature_delta", Signature: part.ThoughtSignature}, + }, nil); err != nil { + return err + } + } + + // 工具调用:Gemini 一次给全量参数,不做增量拼接 + if part.FunctionCall != nil { + if err := closeBlock(); err != nil { + return err + } + if err := emitEvent("content_block_start", &dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "tool_use", + ID: claudeToolUseID(idBase, toolIdx), + Name: part.FunctionCall.FunctionName, + Input: map[string]any{}, + }, + }, nil); err != nil { + return err + } + currentBlockType = "tool_use" + toolIdx++ + hasToolUse = true + + argsJSON, err := json.Marshal(geminiFunctionArgs(part.FunctionCall)) + if err != nil { + argsJSON = []byte("{}") + } + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "input_json_delta", PartialJSON: strPtr(string(argsJSON))}, + }, nil); err != nil { + return err + } + if err := closeBlock(); err != nil { + return err + } + } + + // Claude assistant 内容块无对应类型,按既有 Gemini→OpenAI 口径渲染为文本 + if fallback := geminiPartFallbackText(part); fallback != "" { + if err := openTextLikeBlock("text", &dto.ClaudeContentBlock{Type: "text", Text: strPtr("")}); err != nil { + return err + } + if err := emitEvent("content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{Type: "text_delta", Text: strPtr(fallback)}, + }, nil); err != nil { + return err + } + } + } + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + // 读流出错:已累计的 usage 无法随错误返回(StreamEvent 只随帧携带),由宿主按估算兜底 + return fmt.Errorf("stream scanner error: %w", err) + } + + // 上游一个有效 chunk 都没给:仍需补齐 message_start,否则客户端拿不到完整事件序列 + if !startSent { + if err := emitEvent("message_start", &dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMessageInfo{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []dto.ClaudeContentBlock{}, + Model: modelName, + Usage: &dto.ClaudeUsage{}, + }, + }, nil); err != nil { + return err + } + } + + return emitFinal(true) +} diff --git a/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_test.go b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_test.go new file mode 100644 index 0000000..adc4489 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/gemini_to_claude_test.go @@ -0,0 +1,420 @@ +package claude_gemini + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// claudeInboundMeta 构造 Claude 入站 + Gemini 上游的 Meta +func claudeInboundMeta(isStream bool) *convmeta.Values { + return &convmeta.Values{ + OriginModelName: "gemini-3-pro", + UpstreamModelName: "gemini-3-pro", + ChannelMetaAttached: true, + IsStream: isStream, + } +} + +// runGeminiToClaudeStream 跑流式转换并收集 StreamEvent 序列 +func runGeminiToClaudeStream(t *testing.T, meta convmeta.Meta, upstream string) ([]*relayconvert.StreamEvent, error) { + t.Helper() + conv := &GeminiToClaudeStreamConverter{} + var events []*relayconvert.StreamEvent + err := conv.ConvertStreamResponse(context.Background(), meta, strings.NewReader(upstream), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("chunk 类型应为 *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +// claudeEventTypes 提取事件名序列(校验帧形态:Claude 客户端为事件帧) +func claudeEventTypes(t *testing.T, events []*relayconvert.StreamEvent) []string { + t.Helper() + names := make([]string, 0, len(events)) + for _, ev := range events { + if ev.Event == "" { + t.Fatalf("Claude 客户端应为事件帧(Event 非空): %+v", ev) + } + names = append(names, ev.Event) + } + return names +} + +// claudeDataOf 取事件负载并断言类型 +func claudeDataOf(t *testing.T, ev *relayconvert.StreamEvent) *dto.ClaudeResponse { + t.Helper() + data, ok := ev.Data.(*dto.ClaudeResponse) + if !ok { + t.Fatalf("data 类型应为 *dto.ClaudeResponse, got %T", ev.Data) + } + return data +} + +// ===== 非流式 ===== + +// TestGeminiToClaudeResponse_PreservesPartOrder thinking/text 块按 Gemini parts 原始顺序映射, +// thoughtSignature 落到 Claude 的 signature 字段。 +func TestGeminiToClaudeResponse_PreservesPartOrder(t *testing.T) { + thought := true + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "让我想想", Thought: &thought, ThoughtSignature: "sig-abc"}, + {Text: "答案是 42"}, + }, + }, + FinishReason: "STOP", + }}, + UsageMetadata: &dto.GeminiUsageMetadata{ + PromptTokenCount: 100, + CandidatesTokenCount: 20, + ThoughtsTokenCount: 5, + TotalTokenCount: 125, + }, + } + + conv := &GeminiToClaudeResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), claudeInboundMeta(false), geminiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got, ok := res.(*dto.ClaudeResponse) + if !ok { + t.Fatalf("返回类型应为 *dto.ClaudeResponse, got %T", res) + } + + if got.Type != "message" || got.Role != "assistant" { + t.Errorf("消息骨架不对: type=%q role=%q", got.Type, got.Role) + } + if len(got.Content) != 2 { + t.Fatalf("content 块数 = %d, want 2: %+v", len(got.Content), got.Content) + } + if got.Content[0].Type != "thinking" { + t.Errorf("首块应为 thinking, got %q", got.Content[0].Type) + } + if got.Content[0].Thinking == nil || *got.Content[0].Thinking != "让我想想" { + t.Errorf("thinking 内容不对: %+v", got.Content[0].Thinking) + } + if got.Content[0].Signature != "sig-abc" { + t.Errorf("thoughtSignature 未搬运到 signature: %q", got.Content[0].Signature) + } + if got.Content[1].Type != "text" || got.Content[1].Text == nil || *got.Content[1].Text != "答案是 42" { + t.Errorf("次块应为 text: %+v", got.Content[1]) + } + if got.StopReason != claudeEndTurn { + t.Errorf("stop_reason = %q, want %q", got.StopReason, claudeEndTurn) + } + // 客户端可见用量为 Claude 口径(input 不含缓存,output 含思考) + if got.Usage.InputTokens != 100 || got.Usage.OutputTokens != 25 { + t.Errorf("客户端用量口径不对: %+v", got.Usage) + } +} + +// TestGeminiToClaudeResponse_ToolUse functionCall 合成 tool_use ID, +// 且 stop_reason 必须从 Gemini 的 STOP 改写为 Claude 的 tool_use,否则客户端不会发起工具回合。 +func TestGeminiToClaudeResponse_ToolUse(t *testing.T) { + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: "get_weather", + Arguments: map[string]any{"city": "北京"}, + }}, + {FunctionCall: &dto.GeminiFunctionCall{FunctionName: "get_time"}}, + }, + }, + FinishReason: "STOP", + }}, + } + + conv := &GeminiToClaudeResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), claudeInboundMeta(false), geminiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got := res.(*dto.ClaudeResponse) + + if len(got.Content) != 2 { + t.Fatalf("content 块数 = %d, want 2", len(got.Content)) + } + first, second := got.Content[0], got.Content[1] + if first.Type != "tool_use" || first.Name != "get_weather" { + t.Errorf("首个工具块不对: %+v", first) + } + if first.ID == "" || first.ID == second.ID { + t.Errorf("tool_use ID 必须非空且互不相同: %q / %q", first.ID, second.ID) + } + if !strings.HasPrefix(first.ID, "toolu_") { + t.Errorf("tool_use ID 应为 Claude 风格前缀: %q", first.ID) + } + // Arguments 为 nil 时须回退为空对象,Claude 的 tool_use.input 不允许 null + if second.Input == nil { + t.Errorf("空参数应回退为空对象,got nil") + } + if got.StopReason != claudeToolUse { + t.Errorf("stop_reason = %q, want %q(Gemini 带 functionCall 时仍返回 STOP,须改写)", + got.StopReason, claudeToolUse) + } +} + +// TestGeminiToClaudeResponse_EmptyContent Claude 协议要求 content 非空 +func TestGeminiToClaudeResponse_EmptyContent(t *testing.T) { + conv := &GeminiToClaudeResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), claudeInboundMeta(false), &dto.GeminiChatResponse{}) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got := res.(*dto.ClaudeResponse) + if len(got.Content) != 1 || got.Content[0].Type != "text" { + t.Fatalf("空响应应回退为单个空 text 块: %+v", got.Content) + } +} + +// TestGeminiToClaudeResponse_SafetyBlock 安全过滤命中时返回错误而非合成响应 +func TestGeminiToClaudeResponse_SafetyBlock(t *testing.T) { + conv := &GeminiToClaudeResponseConverter{} + _, err := conv.ConvertResponse(context.Background(), claudeInboundMeta(false), &dto.GeminiChatResponse{ + PromptFeedback: &dto.GeminiPromptFeedback{BlockReason: "SAFETY"}, + }) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + if !strings.Contains(err.Error(), "SAFETY") { + t.Errorf("错误信息应包含 blockReason: %v", err) + } +} + +// TestClaudeUsageFromGemini_CacheAndThoughts Claude 口径:input 不含缓存、output 含思考 +func TestClaudeUsageFromGemini_CacheAndThoughts(t *testing.T) { + um := &dto.GeminiUsageMetadata{ + PromptTokenCount: 100, // Gemini 语义已含 cached + CachedContentTokenCount: 30, + CandidatesTokenCount: 20, // Gemini 语义不含 thoughts + ThoughtsTokenCount: 7, + } + + got := claudeUsageFromGemini(um) + + if got.InputTokens != 70 { + t.Errorf("input_tokens = %d, want 70(须扣除 cached)", got.InputTokens) + } + if got.CacheReadInputTokens != 30 { + t.Errorf("cache_read_input_tokens = %d, want 30", got.CacheReadInputTokens) + } + if got.OutputTokens != 27 { + t.Errorf("output_tokens = %d, want 27(candidates+thoughts)", got.OutputTokens) + } +} + +// TestClaudeInputTokens_NegativeGuard cached 超过 prompt 时不得出现负数 +func TestClaudeInputTokens_NegativeGuard(t *testing.T) { + got := claudeInputTokens(&dto.GeminiUsageMetadata{PromptTokenCount: 10, CachedContentTokenCount: 40}) + if got != 0 { + t.Errorf("input_tokens = %d, want 0", got) + } + if claudeInputTokens(nil) != 0 { + t.Errorf("nil usage 应返回 0") + } +} + +// TestGeminiPartFallbackText Claude 无对应块类型的 part 渲染为文本而非丢弃 +func TestGeminiPartFallbackText(t *testing.T) { + cases := []struct { + name string + part dto.GeminiPart + want string + }{ + {"inlineData", dto.GeminiPart{InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "AAA"}}, + "![image](data:image/png;base64,AAA)"}, + {"fileData", dto.GeminiPart{FileData: &dto.GeminiFileData{FileURI: "gs://b/o"}}, "[file](gs://b/o)"}, + {"executableCode", dto.GeminiPart{ExecutableCode: &dto.GeminiExecutableCode{Language: "python", Code: "print(1)"}}, + "```python\nprint(1)\n```"}, + {"codeExecutionResult", dto.GeminiPart{CodeExecutionResult: &dto.GeminiCodeExecutionResult{Outcome: "OUTCOME_OK", Output: "1"}}, + "Execution OUTCOME_OK:\n1"}, + {"纯文本无回退", dto.GeminiPart{Text: "hi"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := geminiPartFallbackText(&tc.part); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// ===== 流式 ===== + +// TestGeminiToClaudeStream_EventSequence 完整事件序列: +// message_start → content_block_start/delta/stop(thinking 与 text 各成块)→ message_delta → message_stop +func TestGeminiToClaudeStream_EventSequence(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"思考中","thought":true,"thoughtSignature":"sig1"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"世界"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}`, + ``, + ``, + }, "\n") + + events, err := runGeminiToClaudeStream(t, claudeInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := claudeEventTypes(t, events) + want := []string{ + "message_start", + "content_block_start", "content_block_delta", "content_block_delta", // thinking 块 + 文本增量 + signature 增量 + "content_block_stop", + "content_block_start", "content_block_delta", "content_block_delta", // text 块 + 两次增量 + "content_block_stop", + "message_delta", "message_stop", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // thinking 块与 signature + if claudeDataOf(t, events[1]).ContentBlock.Type != "thinking" { + t.Errorf("首块应为 thinking: %+v", claudeDataOf(t, events[1]).ContentBlock) + } + if claudeDataOf(t, events[3]).Delta.Type != "signature_delta" { + t.Errorf("应发出 signature_delta: %+v", claudeDataOf(t, events[3]).Delta) + } + if claudeDataOf(t, events[3]).Delta.Signature != "sig1" { + t.Errorf("signature 内容不对: %+v", claudeDataOf(t, events[3]).Delta) + } + + // 块索引必须连续且成对:thinking=0, text=1 + if idx := claudeDataOf(t, events[4]).Index; idx == nil || *idx != 0 { + t.Errorf("thinking 块 content_block_stop index = %v, want 0", idx) + } + if idx := claudeDataOf(t, events[5]).Index; idx == nil || *idx != 1 { + t.Errorf("text 块 content_block_start index = %v, want 1", idx) + } + + // message_delta 的客户端可见用量为 Claude 口径 + delta := claudeDataOf(t, events[9]) + if delta.Delta.StopReason == nil || *delta.Delta.StopReason != claudeEndTurn { + t.Errorf("stop_reason = %v, want %q", delta.Delta.StopReason, claudeEndTurn) + } + if delta.Usage.InputTokens != 40 { + t.Errorf("客户端 input_tokens = %v, want 40(50-10 cached)", delta.Usage.InputTokens) + } + if delta.Usage.OutputTokens != 11 { + t.Errorf("客户端 output_tokens = %v, want 11(8+3 thoughts)", delta.Usage.OutputTokens) + } + + // 计费用量走 Gemini 口径(completion 含 thoughts,prompt 含 cached),随 message_delta 帧携带 + if events[9].Usage == nil { + t.Fatal("message_delta 帧必须携带计费用量") + } + if events[9].Usage.PromptTokens != 50 || events[9].Usage.CompletionTokens != 11 { + t.Errorf("计费用量 = prompt %d / completion %d, want 50 / 11", + events[9].Usage.PromptTokens, events[9].Usage.CompletionTokens) + } + if events[9].Usage.PromptTokensDetails == nil || events[9].Usage.PromptTokensDetails.CachedTokens != 10 { + t.Errorf("计费缓存明细不对: %+v", events[9].Usage.PromptTokensDetails) + } + if events[9].Usage.CompletionTokenDetails == nil || events[9].Usage.CompletionTokenDetails.ReasoningTokens != 3 { + t.Errorf("计费思考明细不对: %+v", events[9].Usage.CompletionTokenDetails) + } +} + +// TestGeminiToClaudeStream_ToolCall functionCall 转为独立 tool_use 块, +// 参数以 input_json_delta 一次性给出,stop_reason 改写为 tool_use。 +func TestGeminiToClaudeStream_ToolCall(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"北京"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}`, + ``, + ``, + }, "\n") + + events, err := runGeminiToClaudeStream(t, claudeInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := claudeEventTypes(t, events) + want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + block := claudeDataOf(t, events[1]).ContentBlock + if block.Type != "tool_use" || block.Name != "get_weather" { + t.Errorf("tool_use 块不对: %+v", block) + } + if !strings.HasPrefix(block.ID, "toolu_") { + t.Errorf("tool_use id = %q, want toolu_ 前缀", block.ID) + } + + delta := claudeDataOf(t, events[2]).Delta + if delta.Type != "input_json_delta" { + t.Fatalf("参数应以 input_json_delta 给出: %+v", delta) + } + var args map[string]any + if err := json.Unmarshal([]byte(*delta.PartialJSON), &args); err != nil { + t.Fatalf("partial_json 不是合法 JSON: %v", err) + } + if args["city"] != "北京" { + t.Errorf("参数丢失: %+v", args) + } + + final := claudeDataOf(t, events[4]).Delta + if final.StopReason == nil || *final.StopReason != claudeToolUse { + t.Errorf("stop_reason 应改写为 tool_use: %+v", final) + } +} + +// TestGeminiToClaudeStream_EmptyUpstreamStillClosesSequence 上游无有效 chunk 时, +// 仍须补齐 message_start/message_stop,否则 Anthropic 客户端会一直等收尾事件。 +func TestGeminiToClaudeStream_EmptyUpstreamStillClosesSequence(t *testing.T) { + events, err := runGeminiToClaudeStream(t, claudeInboundMeta(true), "\n\n") + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := claudeEventTypes(t, events) + want := []string{"message_start", "message_delta", "message_stop"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } +} + +// TestGeminiToClaudeStream_SafetyBlockClosesSequence 安全过滤命中时事件流已开场, +// 必须补齐收尾事件再返回错误,否则客户端挂起。 +func TestGeminiToClaudeStream_SafetyBlockClosesSequence(t *testing.T) { + upstream := "data: {\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n" + + events, err := runGeminiToClaudeStream(t, claudeInboundMeta(true), upstream) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + + got := claudeEventTypes(t, events) + if len(got) == 0 || got[len(got)-1] != "message_stop" { + t.Errorf("必须以 message_stop 收尾, got %v", got) + } + // 与旧桥一致:安全过滤路径不产出计费用量 + for _, ev := range events { + if ev.Usage != nil { + t.Errorf("安全过滤路径不应携带计费用量: %+v", ev) + } + } +} diff --git a/relaykit/relayconvert/internal/claude_gemini/helper.go b/relaykit/relayconvert/internal/claude_gemini/helper.go new file mode 100644 index 0000000..b168469 --- /dev/null +++ b/relaykit/relayconvert/internal/claude_gemini/helper.go @@ -0,0 +1,200 @@ +// Package claude_gemini 承载 Claude Messages ↔ Gemini GenerateContent 的跨原生桥。 +// +// 响应侧:把一方上游的响应/SSE 转回另一方客户端格式,双向均为直连转换器。代码从宿主 +// relay/channel/claude/gemini_bridge.go 与 relay/channel/gemini/claude_bridge.go 逐行平移, +// 宿主侧的 SSE 写出、SetFirstResponseTime、StreamStatus、ping 保活不在本层, +// 流式输出统一以 relayconvert.StreamEvent 交由宿主桥接层帧化。 +// +// 请求侧:Claude→Gemini 由本包直连(claude_to_gemini_request.go,不经中枢格式, +// 可保住 thinking 签名、精确 budget、top_k 等);Gemini→Claude 仍走 +// 「经 OpenAI 中枢」的两段式步骤链,未在本包实现。 +package claude_gemini + +import ( + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// ========== stop_reason / finishReason 映射(与宿主 relay/common/reasonmap.go 同口径) ========== + +// Claude stop_reason 取值 +const ( + claudeEndTurn = "end_turn" + claudeMaxTokens = "max_tokens" + claudeToolUse = "tool_use" + claudeStopSequence = "stop_sequence" + claudePauseTurn = "pause_turn" // 扩展思考暂停 + claudeRefusal = "refusal" // 内容拒绝 +) + +// Gemini finishReason 取值 +const ( + geminiSTOP = "STOP" + geminiMAX_TOKENS = "MAX_TOKENS" + geminiSAFETY = "SAFETY" + geminiRECITATION = "RECITATION" + geminiOTHER = "OTHER" + geminiBLOCKLIST = "BLOCKLIST" + geminiPROHIBITED = "PROHIBITED" + geminiSPII = "SPII" + geminiMALFORMED_FUNCTION_CALL = "MALFORMED_FUNCTION_CALL" + geminiLANGUAGE = "LANGUAGE" + geminiIMAGE_SAFETY = "IMAGE_SAFETY" + geminiIMAGE_PROHIBITED_CONTENT = "IMAGE_PROHIBITED_CONTENT" + geminiIMAGE_OTHER = "IMAGE_OTHER" + geminiNO_IMAGE = "NO_IMAGE" + geminiIMAGE_RECITATION = "IMAGE_RECITATION" + geminiUNEXPECTED_TOOL_CALL = "UNEXPECTED_TOOL_CALL" + geminiTOO_MANY_TOOL_CALLS = "TOO_MANY_TOOL_CALLS" + geminiMISSING_THOUGHT_SIGNATURE = "MISSING_THOUGHT_SIGNATURE" + geminiMALFORMED_RESPONSE = "MALFORMED_RESPONSE" + geminiFINISH_REASON_UNSPECIFIED = "FINISH_REASON_UNSPECIFIED" +) + +// claudeStopReasonToGemini 将 Claude stop_reason 转换为 Gemini finishReason +func claudeStopReasonToGemini(reason string) string { + switch reason { + case claudeEndTurn, claudeStopSequence, claudePauseTurn: + return geminiSTOP + case claudeMaxTokens: + return geminiMAX_TOKENS + case claudeToolUse: + return geminiSTOP + case claudeRefusal: + return geminiSAFETY + default: + return reason + } +} + +// geminiFinishReasonToClaude 将 Gemini finishReason 转换为 Claude stop_reason +func geminiFinishReasonToClaude(reason string) string { + switch reason { + case geminiSTOP, geminiFINISH_REASON_UNSPECIFIED, geminiMISSING_THOUGHT_SIGNATURE, geminiMALFORMED_RESPONSE: + return claudeEndTurn + case geminiMAX_TOKENS: + return claudeMaxTokens + case geminiSAFETY, geminiRECITATION, geminiOTHER, geminiBLOCKLIST, geminiPROHIBITED, geminiSPII, + geminiLANGUAGE, geminiIMAGE_SAFETY, geminiIMAGE_PROHIBITED_CONTENT, geminiIMAGE_OTHER, + geminiNO_IMAGE, geminiIMAGE_RECITATION: + return claudeRefusal + case geminiMALFORMED_FUNCTION_CALL, geminiUNEXPECTED_TOOL_CALL, geminiTOO_MANY_TOOL_CALLS: + return claudeToolUse + default: + return reason + } +} + +// ========== Meta 相关辅助 ========== + +// originModelName 取入站模型名(nil Meta 安全) +func originModelName(info convmeta.Meta) string { + if info == nil { + return "" + } + return info.GetOriginModelName() +} + +// isModelMapped 判断是否经过模型名映射(宿主 info.ChannelMeta.IsModelMapped 的等价判定: +// 附加了渠道信息且上游模型名与入站模型名不同) +func isModelMapped(info convmeta.Meta) bool { + if info == nil || !info.HasChannelMeta() { + return false + } + upstream := info.GetUpstreamModelName() + return upstream != "" && upstream != info.GetOriginModelName() +} + +// ========== 计费用量换算(StreamEvent.Usage 载荷,带缓存明细) ========== + +// claudeBillingUsage 按 Claude 计费口径换算(与宿主 buildUsageFromClaude 等价: +// input 不含缓存,cache 读/写明细随 PromptTokensDetails 透传) +func claudeBillingUsage(u *dto.ClaudeUsage) *dto.UsageWithDetails { + if u == nil { + return &dto.UsageWithDetails{} + } + return &dto.UsageWithDetails{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.InputTokens + u.OutputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(u), + } +} + +// claudeUsageToTokenDetails 将 ClaudeUsage 转换为 TokenDetails(含 cache token 细分), +// 与宿主同名函数等价 +func claudeUsageToTokenDetails(u *dto.ClaudeUsage) *dto.TokenDetails { + if u == nil { + return nil + } + td := &dto.TokenDetails{ + CachedTokens: u.CacheReadInputTokens, + CachedCreationTokens: u.CacheCreationInputTokens, + } + if u.CacheCreation != nil { + td.CachedCreation5mTokens = u.CacheCreation.Ephemeral5mInputTokens + td.CachedCreation1hTokens = u.CacheCreation.Ephemeral1hInputTokens + } + return td +} + +// geminiBillingUsage 按 Gemini 计费口径换算(与宿主 geminiUsageToCommon 等价: +// prompt 含 cached、completion 含 thoughts、模态明细透传)。 +// 宿主 common.Usage 的 CacheIncludedInPrompt 标志在 UsageWithDetails 上无对应字段, +// 由宿主桥接层按「Gemini 上游」方向补挂。 +func geminiBillingUsage(um *dto.GeminiUsageMetadata) *dto.UsageWithDetails { + if um == nil { + return &dto.UsageWithDetails{} + } + usage := &dto.UsageWithDetails{ + PromptTokens: um.PromptTokenCount, + // Gemini 的 candidatesTokenCount 不含思考 token,thoughtsTokenCount 是输出侧 + // 独立字段(按输出价计费)。OpenAI 口径的 completion 含 reasoning(子集语义), + // 计费用量必须 candidates+thoughts 合计,否则思考 token 漏计费 + CompletionTokens: um.CandidatesTokenCount + um.ThoughtsTokenCount, + TotalTokens: um.TotalTokenCount, + // Gemini 的 promptTokenCount 已含 cachedContentTokenCount(cached 为其子集) + PromptTokensDetails: &dto.TokenDetails{ + CachedTokens: um.CachedContentTokenCount, + }, + CompletionTokenDetails: &dto.TokenDetails{ + ReasoningTokens: um.ThoughtsTokenCount, + }, + } + + // 转换模态 Token 明细 + for _, mtc := range um.PromptTokensDetails { + geminiModalityToTokenDetails(mtc, usage.PromptTokensDetails) + } + for _, mtc := range um.CandidatesTokensDetails { + geminiModalityToTokenDetails(mtc, usage.CompletionTokenDetails) + } + + return usage +} + +// geminiModalityToTokenDetails 将 Gemini 模态 Token 计数转换为 TokenDetails 字段 +func geminiModalityToTokenDetails(mtc dto.GeminiModalityTokenCount, td *dto.TokenDetails) { + if td == nil { + return + } + switch mtc.Modality { + case "TEXT": + td.TextTokens += mtc.TokenCount + case "IMAGE": + td.ImageTokens += mtc.TokenCount + case "AUDIO": + td.AudioTokens += mtc.TokenCount + } +} + +// ========== 指针辅助 ========== + +// boolPtr 返回 bool 的指针 +func boolPtr(v bool) *bool { return &v } + +// intPtr 返回 int 的指针 +func intPtr(v int) *int { return &v } + +// strPtr 返回 string 的指针 +func strPtr(v string) *string { return &v } diff --git a/relaykit/relayconvert/internal/coze_chat/benchmark_test.go b/relaykit/relayconvert/internal/coze_chat/benchmark_test.go deleted file mode 100644 index 20bba64..0000000 --- a/relaykit/relayconvert/internal/coze_chat/benchmark_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package coze_chat - -import ( - "context" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" -) - -func BenchmarkOpenAIToCozeRequest(b *testing.B) { - conv := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Model: "bot-origin", - Messages: []dto.Message{ - {Role: "system", Content: "You are a helpful assistant."}, - {Role: "user", Content: "Summarize the latest question in one line."}, - }, - } - info := &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "bot-mapped"} - ctx := context.Background() - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - if _, err := conv.ConvertRequest(ctx, info, req); err != nil { - b.Fatal(err) - } - } -} diff --git a/relaykit/relayconvert/internal/coze_chat/coze_chat_test.go b/relaykit/relayconvert/internal/coze_chat/coze_chat_test.go deleted file mode 100644 index 1f18219..0000000 --- a/relaykit/relayconvert/internal/coze_chat/coze_chat_test.go +++ /dev/null @@ -1,220 +0,0 @@ -package coze_chat - -import ( - "bytes" - "context" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// ---------- 请求转换 ---------- - -func TestOpenAIToCozeRequestConverter_Metadata(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - if c.ID() != relayconvert.ConverterOpenAIChatToCoze { - t.Errorf("ID() = %q", c.ID()) - } - if c.From() != types.RelayFormatOpenAI || c.To() != types.RelayFormatCoze { - t.Errorf("From/To = %q/%q", c.From(), c.To()) - } -} - -func TestOpenAIToCozeRequestConverter_Basic(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Model: "bot-origin", - Messages: []dto.Message{ - {Role: "system", Content: "sys"}, - {Role: "user", Content: "first question"}, - {Role: "assistant", Content: "answer"}, - {Role: "user", Content: "latest question"}, - }, - } - info := &convmeta.Values{UpstreamModelName: "bot-mapped"} - res, err := c.ConvertRequest(context.Background(), info, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - r := res.(*dto.CozeCreateRequest) - if r.Query != "latest question" { - t.Errorf("Query = %q, want latest question (last user msg)", r.Query) - } - if r.BotID != "bot-mapped" { - t.Errorf("BotID = %q, want bot-mapped (upstream)", r.BotID) - } - if !r.Stream { - t.Error("Stream must be forced true") - } -} - -func TestOpenAIToCozeRequestConverter_BotIDFallbackToModel(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Model: "bot-origin", - Messages: []dto.Message{{Role: "user", Content: "q"}}, - } - // 无 info(或 UpstreamModelName 为空):bot_id 回退到客户端模型名 - res, err := c.ConvertRequest(context.Background(), nil, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - if r := res.(*dto.CozeCreateRequest); r.BotID != "bot-origin" { - t.Errorf("BotID = %q, want bot-origin", r.BotID) - } -} - -func TestOpenAIToCozeRequestConverter_DefaultUser(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{Model: "b", Messages: []dto.Message{{Role: "user", Content: "q"}}} - res, _ := c.ConvertRequest(context.Background(), nil, req) - if r := res.(*dto.CozeCreateRequest); r.User != "relay-user" { - t.Errorf("User = %q", r.User) - } -} - -func TestOpenAIToCozeRequestConverter_NoUserMessage(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{Role: "system", Content: "sys"}}, - } - if _, err := c.ConvertRequest(context.Background(), nil, req); err == nil { - t.Fatal("want error when no user message") - } -} - -func TestOpenAIToCozeRequestConverter_MultimodalLastUser(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{ - Role: "user", - Content: []any{ - map[string]any{"type": "image_url", "image_url": map[string]any{"url": "x"}}, - map[string]any{"type": "text", "text": "describe this"}, - }, - }}, - } - res, err := c.ConvertRequest(context.Background(), nil, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - if r := res.(*dto.CozeCreateRequest); r.Query != "describe this" { - t.Errorf("Query = %q", r.Query) - } -} - -func TestOpenAIToCozeRequestConverter_InvalidType(t *testing.T) { - c := &OpenAIToCozeRequestConverter{} - if _, err := c.ConvertRequest(context.Background(), nil, 42); err == nil { - t.Fatal("want error for wrong type") - } -} - -// ---------- 非流式响应转换(解析缓冲 SSE) ---------- - -func TestCozeToOpenAIResponseConverter_Basic(t *testing.T) { - c := &CozeToOpenAIResponseConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"Hel"}` + "\n" + - "event: conversation.message.completed\n" + - `data: {"role":"assistant","type":"answer","content":"Hello"}` + "\n" - res, err := c.ConvertResponse(context.Background(), nil, []byte(sse)) - if err != nil { - t.Fatalf("ConvertResponse: %v", err) - } - o := res.(*dto.ChatCompletionResponse) - // completed 事件优先,应取 "Hello" - if o.Choices[0].Message.Content != "Hello" { - t.Errorf("content = %q, want Hello", o.Choices[0].Message.Content) - } - if o.Usage.CompletionTokens != 1 { // "Hello" len 5 → 1 - t.Errorf("estimated CompletionTokens = %d, want 1", o.Usage.CompletionTokens) - } -} - -func TestCozeToOpenAIResponseConverter_OnlyDelta(t *testing.T) { - c := &CozeToOpenAIResponseConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"Hi"}` + "\n" + - "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":" there"}` + "\n" - res, _ := c.ConvertResponse(context.Background(), nil, []byte(sse)) - o := res.(*dto.ChatCompletionResponse) - if o.Choices[0].Message.Content != "Hi there" { - t.Errorf("content = %q, want 'Hi there'", o.Choices[0].Message.Content) - } -} - -func TestCozeToOpenAIResponseConverter_ErrorEvent(t *testing.T) { - c := &CozeToOpenAIResponseConverter{} - sse := "event: error\ndata: {\"code\":500}\n" - if _, err := c.ConvertResponse(context.Background(), nil, []byte(sse)); err == nil { - t.Fatal("want error for coze error event") - } -} - -func TestCozeToOpenAIResponseConverter_InvalidType(t *testing.T) { - c := &CozeToOpenAIResponseConverter{} - if _, err := c.ConvertResponse(context.Background(), nil, "not-bytes"); err == nil { - t.Fatal("want error for wrong type") - } -} - -// ---------- 流式响应转换 ---------- - -func TestCozeToOpenAIStreamConverter_Basic(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"Hello"}` + "\n" + - "event: conversation.message.completed\n" + - `data: {"role":"assistant","type":"answer","content":"Hello world"}` + "\n" + - "event: done\ndata: {}\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, bytes.NewReader([]byte(sse)), func(chunk any) error { - if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { - chunks = append(chunks, sc) - } - return nil - }) - if err != nil { - t.Fatalf("ConvertStreamResponse: %v", err) - } - // role chunk + content delta + finish chunk(completed) - if len(chunks) != 3 { - t.Fatalf("chunks = %d, want 3", len(chunks)) - } - if chunks[0].Choices[0].Delta.Role != "assistant" { - t.Errorf("first chunk role = %q", chunks[0].Choices[0].Delta.Role) - } - if chunks[1].Choices[0].Delta.Content != "Hello" { - t.Errorf("content delta = %q", chunks[1].Choices[0].Delta.Content) - } - if chunks[2].Choices[0].FinishReason == nil || *chunks[2].Choices[0].FinishReason != "stop" { - t.Errorf("finish chunk missing/incorrect: %v", chunks[2].Choices[0].FinishReason) - } -} - -func TestCozeToOpenAIStreamConverter_NonAnswerIgnored(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"follow_up","content":"ignored"}` + "\n" + - "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"real"}` + "\n" + - "event: done\ndata: {}\n" - var chunks []*dto.ChatCompletionStreamResponse - _ = c.ConvertStreamResponse(context.Background(), nil, bytes.NewReader([]byte(sse)), func(chunk any) error { - if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { - chunks = append(chunks, sc) - } - return nil - }) - // role + content("real") + finish(done 兜底) = 3;非 answer 不产出内容 chunk - for _, ch := range chunks { - if c := ch.Choices[0].Delta.Content; c == "ignored" { - t.Errorf("non-answer content should be ignored, got %q", c) - } - } -} diff --git a/relaykit/relayconvert/internal/coze_chat/coze_to_openai_response.go b/relaykit/relayconvert/internal/coze_chat/coze_to_openai_response.go deleted file mode 100644 index de8138e..0000000 --- a/relaykit/relayconvert/internal/coze_chat/coze_to_openai_response.go +++ /dev/null @@ -1,129 +0,0 @@ -package coze_chat - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// CozeToOpenAIResponseConverter 将 Coze 响应转换为 OpenAI Chat Completions 响应。 -// -// Coze 上游始终返回 SSE(即使非流式客户端,请求侧也已强制 Stream=true)。 -// 非流式场景下宿主已把整段 SSE 缓冲为 []byte,本转换器扫描该缓冲、抽取 -// conversation.message.completed / delta 事件中的 answer 内容。 -type CozeToOpenAIResponseConverter struct{} - -func (c *CozeToOpenAIResponseConverter) ID() string { - return relayconvert.ResponseConverterCozeChatToOAIChat -} - -func (c *CozeToOpenAIResponseConverter) From() types.RelayFormat { - return types.RelayFormatCoze -} - -func (c *CozeToOpenAIResponseConverter) To() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *CozeToOpenAIResponseConverter) Quality() relayconvert.ResponseConverterQuality { - return relayconvert.ResponseConverterQualityFair -} - -// ConvertResponse 解析 Coze SSE 缓冲体,构建 OpenAI 非流式响应。 -// response 期望为 []byte(宿主缓冲的整段 SSE)。 -func (c *CozeToOpenAIResponseConverter) ConvertResponse( - ctx context.Context, - info convmeta.Meta, - response any, -) (any, error) { - raw, ok := response.([]byte) - if !ok { - return nil, fmt.Errorf("expected []byte (buffered Coze SSE), got %T", response) - } - - var ( - fullContent strings.Builder - currentEvent string - gotCompleted bool - ) - - scanner := bufio.NewScanner(strings.NewReader(string(raw))) - scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) - - for scanner.Scan() { - line := scanner.Text() - - if strings.HasPrefix(line, "event:") { - currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "" { - continue - } - - switch currentEvent { - case "conversation.message.delta": - var msg dto.CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - continue - } - if msg.Type == "answer" && !gotCompleted { - fullContent.WriteString(msg.Content) - } - - case "conversation.message.completed": - // completed 事件包含完整内容,优先使用 - var msg dto.CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err == nil && msg.Type == "answer" { - fullContent.Reset() - fullContent.WriteString(msg.Content) - gotCompleted = true - } - - case "error": - return nil, fmt.Errorf("coze error: %s", data) - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("scan coze response failed: %w", err) - } - - content := fullContent.String() - modelName := "" - if info != nil { - modelName = info.GetOriginModelName() - } - - openaiResp := &dto.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-%d", getCurrentTimestamp()), - Object: "chat.completion", - Created: 0, - Model: modelName, - Choices: []dto.Choice{{ - Index: 0, - Message: dto.Message{ - Role: "assistant", - Content: content, - }, - FinishReason: "stop", - }}, - Usage: dto.UsageWithDetails{ - CompletionTokens: estimateTokens(content), - TotalTokens: estimateTokens(content), - }, - } - - return openaiResp, nil -} diff --git a/relaykit/relayconvert/internal/coze_chat/coze_to_openai_stream.go b/relaykit/relayconvert/internal/coze_chat/coze_to_openai_stream.go deleted file mode 100644 index 78115b3..0000000 --- a/relaykit/relayconvert/internal/coze_chat/coze_to_openai_stream.go +++ /dev/null @@ -1,175 +0,0 @@ -package coze_chat - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// CozeToOpenAIStreamConverter 将 Coze SSE 流转换为 OpenAI Chat Completions SSE。 -// -// Coze SSE 为事件类型化(event: + data: 行): -// -// event: conversation.message.delta -// data: {"role":"assistant","type":"answer","content":"Hello"} -// -// event: conversation.message.completed -// data: {"role":"assistant","type":"answer","content":"Hello world"} -// -// event: done -// data: {} -type CozeToOpenAIStreamConverter struct{} - -func (c *CozeToOpenAIStreamConverter) ID() string { - return relayconvert.ResponseConverterCozeChatToOAIChatStream -} - -func (c *CozeToOpenAIStreamConverter) From() types.RelayFormat { - return types.RelayFormatCoze -} - -func (c *CozeToOpenAIStreamConverter) To() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *CozeToOpenAIStreamConverter) Quality() relayconvert.ResponseConverterQuality { - return relayconvert.ResponseConverterQualityFair -} - -// ConvertStreamResponse 将 Coze SSE 流转换为 OpenAI SSE 流。 -func (c *CozeToOpenAIStreamConverter) ConvertStreamResponse( - ctx context.Context, - info convmeta.Meta, - reader io.Reader, - chunkWriter func(chunk any) error, -) error { - scanner := bufio.NewScanner(reader) - scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) - - responseID := fmt.Sprintf("chatcmpl-%d", getCurrentTimestamp()) - - modelName := "" - if info != nil { - modelName = info.GetOriginModelName() - } - - newChunk := func(delta dto.Message, finishReason *string) *dto.ChatCompletionStreamResponse { - m := modelName - if m == "" { - m = "coze" - } - chunk := &dto.ChatCompletionStreamResponse{ - ID: responseID, - Object: "chat.completion.chunk", - Created: 0, - Model: m, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: delta, - }}, - } - if finishReason != nil { - chunk.Choices[0].FinishReason = finishReason - } - return chunk - } - - var ( - currentEvent string - roleChunkSent bool - finishEmitted bool - ) - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - line := scanner.Text() - - if strings.HasPrefix(line, "event:") { - currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - continue - } - - if !strings.HasPrefix(line, "data:") { - continue - } - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "" { - continue - } - - switch currentEvent { - case "conversation.message.delta": - var msg dto.CozeMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - continue - } - // 只转发 answer 类型 - if msg.Type != "answer" || msg.Content == "" { - continue - } - if !roleChunkSent { - emptyContent := "" - if err := chunkWriter(newChunk(dto.Message{Role: "assistant", Content: &emptyContent}, nil)); err != nil { - return err - } - roleChunkSent = true - } - if err := chunkWriter(newChunk(dto.Message{Content: msg.Content}, nil)); err != nil { - return err - } - - case "conversation.message.completed": - // 完成事件:发送带 finish_reason 的最终 chunk - reason := "stop" - if err := chunkWriter(newChunk(dto.Message{}, &reason)); err != nil { - return err - } - finishEmitted = true - - case "done": - // 流结束 - if !finishEmitted { - reason := "stop" - if err := chunkWriter(newChunk(dto.Message{}, &reason)); err != nil { - return err - } - } - return nil - - case "error": - return fmt.Errorf("coze stream error: %s", data) - } - } - - // 流异常结束(未收到 done/completed):补发终止 chunk - if !finishEmitted { - if !roleChunkSent { - emptyContent := "" - if err := chunkWriter(newChunk(dto.Message{Role: "assistant", Content: &emptyContent}, nil)); err != nil { - return err - } - } - reason := "stop" - if err := chunkWriter(newChunk(dto.Message{}, &reason)); err != nil { - return err - } - } - - if err := scanner.Err(); err != nil && err != io.EOF { - return fmt.Errorf("stream scanner error: %w", err) - } - return nil -} diff --git a/relaykit/relayconvert/internal/coze_chat/helper.go b/relaykit/relayconvert/internal/coze_chat/helper.go deleted file mode 100644 index 9ea2984..0000000 --- a/relaykit/relayconvert/internal/coze_chat/helper.go +++ /dev/null @@ -1,12 +0,0 @@ -package coze_chat - -// getCurrentTimestamp 返回固定时间戳,保证 golden 测试稳定(与 oai_gemini 一致)。 -// 生产侧响应 ID 的真实化待后续灰度对齐。 -func getCurrentTimestamp() int64 { - return 1700000000 -} - -// estimateTokens 粗略估算 token 数(4 字符 ≈ 1 token),与旧实现 helper.EstimateTokens 口径一致。 -func estimateTokens(s string) int { - return len(s) / 4 -} diff --git a/relaykit/relayconvert/internal/coze_chat/metadata_test.go b/relaykit/relayconvert/internal/coze_chat/metadata_test.go deleted file mode 100644 index 9818166..0000000 --- a/relaykit/relayconvert/internal/coze_chat/metadata_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package coze_chat - -import ( - "testing" - - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/types" -) - -// TestConverterMetadata 覆盖响应/流式转换器的 ID/From/To/Quality 元数据方法。 -func TestConverterMetadata(t *testing.T) { - resp := &CozeToOpenAIResponseConverter{} - if resp.ID() != relayconvert.ResponseConverterCozeChatToOAIChat { - t.Errorf("resp ID = %q", resp.ID()) - } - if resp.From() != types.RelayFormatCoze || resp.To() != types.RelayFormatOpenAI { - t.Errorf("resp From/To = %q/%q", resp.From(), resp.To()) - } - if resp.Quality() != relayconvert.ResponseConverterQualityFair { - t.Errorf("resp Quality = %q", resp.Quality()) - } - - stream := &CozeToOpenAIStreamConverter{} - if stream.ID() != relayconvert.ResponseConverterCozeChatToOAIChatStream { - t.Errorf("stream ID = %q", stream.ID()) - } - if stream.From() != types.RelayFormatCoze || stream.To() != types.RelayFormatOpenAI { - t.Errorf("stream From/To = %q/%q", stream.From(), stream.To()) - } - if stream.Quality() != relayconvert.ResponseConverterQualityFair { - t.Errorf("stream Quality = %q", stream.Quality()) - } -} diff --git a/relaykit/relayconvert/internal/coze_chat/openai_to_coze_request.go b/relaykit/relayconvert/internal/coze_chat/openai_to_coze_request.go deleted file mode 100644 index 8820c6b..0000000 --- a/relaykit/relayconvert/internal/coze_chat/openai_to_coze_request.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package coze_chat 实现 OpenAI Chat Completions ↔ Coze v3 双向转换器。 -// -// Coze v3 的 chat 端点以 BotID 标识机器人、以 Query 携带用户输入,且只支持流式 -// (非流式需轮询,复杂且慢)。因此请求侧强制 Stream=true,把最后一条 user 消息 -// 文本作为 Query;响应侧把 Coze SSE 事件转回 OpenAI 格式(非流式客户端由宿主 -// 把整段 SSE 缓冲后交给非流式转换器解析)。 -package coze_chat - -import ( - "context" - "fmt" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// OpenAIToCozeRequestConverter 将 OpenAI Chat Completions 请求转换为 Coze v3 请求。 -type OpenAIToCozeRequestConverter struct{} - -func (c *OpenAIToCozeRequestConverter) ID() string { - return relayconvert.ConverterOpenAIChatToCoze -} - -func (c *OpenAIToCozeRequestConverter) From() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *OpenAIToCozeRequestConverter) To() types.RelayFormat { - return types.RelayFormatCoze -} - -func (c *OpenAIToCozeRequestConverter) Quality() relayconvert.RequestConverterQuality { - return relayconvert.RequestConverterQualityFair -} - -// ConvertRequest 将 OpenAI 请求转换为 Coze v3 创建对话请求。 -// 强制 Stream=true:Coze 非流式需轮询,统一走流式,非流式场景由宿主缓冲后解析。 -func (c *OpenAIToCozeRequestConverter) ConvertRequest( - ctx context.Context, - info convmeta.Meta, - request any, -) (any, error) { - openaiReq, ok := request.(*dto.GeneralOpenAIRequest) - if !ok { - return nil, fmt.Errorf("expected *dto.GeneralOpenAIRequest, got %T", request) - } - - // Query 取最后一条 user 消息文本 - query := extractLastUserMessage(openaiReq.Messages) - if query == "" { - return nil, fmt.Errorf("no user message found in request") - } - - // BotID:优先映射后的上游模型名(渠道模型映射目标),否则用客户端请求的模型名 - botID := openaiReq.Model - if info != nil { - if upstream := info.GetUpstreamModelName(); upstream != "" { - botID = upstream - } - } - - // 用户标识:Coze 要求非空。relaykit 无法访问 tenant/user 上下文(Meta 未暴露), - // 用客户端 User 或通用占位(与 Dify 一致);per-用户归因待后续灰度对齐。 - user := openaiReq.User - if user == "" { - user = "relay-user" - } - - cozeReq := &dto.CozeCreateRequest{ - BotID: botID, - User: user, - Query: query, - Stream: true, // 强制流式 - } - - return cozeReq, nil -} - -// extractLastUserMessage 从消息列表中提取最后一条 user 角色的文本内容。 -func extractLastUserMessage(messages []dto.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role != "user" { - continue - } - switch content := messages[i].Content.(type) { - case string: - return content - case []any: - for _, part := range content { - m, ok := part.(map[string]any) - if !ok { - continue - } - if t, _ := m["type"].(string); t == "text" { - if text, ok := m["text"].(string); ok { - return text - } - } - } - } - } - return "" -} diff --git a/relaykit/relayconvert/internal/coze_chat/stream_edge_test.go b/relaykit/relayconvert/internal/coze_chat/stream_edge_test.go deleted file mode 100644 index d0e52cc..0000000 --- a/relaykit/relayconvert/internal/coze_chat/stream_edge_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package coze_chat - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/stretchr/testify/assert" -) - -// collectChunks 返回一个收集 *dto.ChatCompletionStreamResponse 的 chunkWriter。 -func collectChunks(out *[]*dto.ChatCompletionStreamResponse) func(any) error { - return func(chunk any) error { - if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { - *out = append(*out, sc) - } - return nil - } -} - -func TestCozeStream_ErrorEvent(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - sse := "event: error\ndata: {\"code\":500}\n" - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(nil)) - assert.Error(t, err) - assert.Contains(t, err.Error(), "coze stream error") -} - -func TestCozeStream_ContextCanceled(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - sse := "event: conversation.message.delta\ndata: {\"role\":\"assistant\",\"type\":\"answer\",\"content\":\"hi\"}\n" - err := c.ConvertStreamResponse(ctx, nil, strings.NewReader(sse), collectChunks(nil)) - assert.ErrorIs(t, err, context.Canceled) -} - -func TestCozeStream_ChunkWriterError(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n" - boomer := func(any) error { return errors.New("boom") } - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), boomer) - assert.ErrorContains(t, err, "boom") -} - -func TestCozeStream_AbnormalEOF_NoAnswer(t *testing.T) { - // 仅有非 answer 的 delta,无 done/completed:循环不产出,结尾补 role + finish - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"follow_up","content":"x"}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - // role chunk + finish chunk - if assert.Len(t, chunks, 2) { - assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role) - assert.NotNil(t, chunks[1].Choices[0].FinishReason) - } -} - -func TestCozeStream_AbnormalEOF_WithContent(t *testing.T) { - // 有 answer delta 但无 done/completed:结尾补 finish - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - // role + content + finish - if assert.Len(t, chunks, 3) { - assert.NotNil(t, chunks[2].Choices[0].FinishReason) - } -} - -func TestCozeStream_DoneWithoutCompleted(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n" + - "event: done\ndata: {}\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - // role + content + finish(done 兜底) - if assert.Len(t, chunks, 3) { - assert.NotNil(t, chunks[2].Choices[0].FinishReason) - } -} - -func TestCozeStream_MalformedAndEmptyData(t *testing.T) { - // 空 data 与畸形 JSON 应被跳过,不 panic、不产出内容 chunk - c := &CozeToOpenAIStreamConverter{} - sse := "event: conversation.message.delta\ndata: \n" + - "event: conversation.message.delta\ndata: not-json\n" + - "event: done\ndata: {}\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - // 仅 done 兜底的 finish chunk(无内容) - if assert.Len(t, chunks, 1) { - assert.NotNil(t, chunks[0].Choices[0].FinishReason) - } -} - -func TestCozeStream_OriginModelName(t *testing.T) { - c := &CozeToOpenAIStreamConverter{} - info := &convmeta.Values{OriginModelName: "my-model"} - sse := "event: conversation.message.delta\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n" + - "event: done\ndata: {}\n" - var chunks []*dto.ChatCompletionStreamResponse - _ = c.ConvertStreamResponse(context.Background(), info, strings.NewReader(sse), collectChunks(&chunks)) - if assert.NotEmpty(t, chunks) { - assert.Equal(t, "my-model", chunks[0].Model) - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/benchmark_test.go b/relaykit/relayconvert/internal/dify_chat/benchmark_test.go deleted file mode 100644 index 0a315dd..0000000 --- a/relaykit/relayconvert/internal/dify_chat/benchmark_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package dify_chat - -import ( - "context" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" -) - -func BenchmarkOpenAIToDifyRequest(b *testing.B) { - conv := &OpenAIToDifyRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Model: "gpt-4", - Messages: []dto.Message{ - {Role: "system", Content: "You are a helpful assistant."}, - {Role: "user", Content: "Translate the following text to French."}, - }, - } - info := &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "dify-bot", IsStream: true} - ctx := context.Background() - b.ReportAllocs() - b.ResetTimer() - for i := 0; i < b.N; i++ { - if _, err := conv.ConvertRequest(ctx, info, req); err != nil { - b.Fatal(err) - } - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/dify_chat_test.go b/relaykit/relayconvert/internal/dify_chat/dify_chat_test.go deleted file mode 100644 index 73f1e85..0000000 --- a/relaykit/relayconvert/internal/dify_chat/dify_chat_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package dify_chat - -import ( - "bytes" - "context" - "strings" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// ---------- 请求转换 ---------- - -func TestOpenAIToDifyRequestConverter_Metadata(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - if c.ID() != relayconvert.ConverterOpenAIChatToDify { - t.Errorf("ID() = %q", c.ID()) - } - if c.From() != types.RelayFormatOpenAI || c.To() != types.RelayFormatDify { - t.Errorf("From/To = %q/%q", c.From(), c.To()) - } - if c.Quality() != relayconvert.RequestConverterQualityFair { - t.Errorf("Quality() = %q", c.Quality()) - } -} - -func TestOpenAIToDifyRequestConverter_BasicFlatten(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - User: "u1", - Messages: []dto.Message{ - {Role: "system", Content: "be helpful"}, - {Role: "user", Content: "hello"}, - }, - } - res, err := c.ConvertRequest(context.Background(), nil, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - d, ok := res.(*dto.DifyRequest) - if !ok { - t.Fatalf("type %T", res) - } - if d.ResponseMode != "blocking" { - t.Errorf("ResponseMode = %q, want blocking (nil info)", d.ResponseMode) - } - if !strings.Contains(d.Query, "System: be helpful") || !strings.Contains(d.Query, "User: hello") { - t.Errorf("Query not flattened: %q", d.Query) - } - if d.User != "u1" { - t.Errorf("User = %q", d.User) - } -} - -func TestOpenAIToDifyRequestConverter_StreamMode(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{Role: "user", Content: "hi"}}, - } - info := &convmeta.Values{IsStream: true} - res, err := c.ConvertRequest(context.Background(), info, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - d := res.(*dto.DifyRequest) - if d.ResponseMode != "streaming" { - t.Errorf("ResponseMode = %q, want streaming", d.ResponseMode) - } -} - -func TestOpenAIToDifyRequestConverter_DefaultUser(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{Role: "user", Content: "hi"}}, - } - res, _ := c.ConvertRequest(context.Background(), nil, req) - if d := res.(*dto.DifyRequest); d.User != "relay-user" { - t.Errorf("User = %q, want relay-user", d.User) - } -} - -func TestOpenAIToDifyRequestConverter_NoMessages(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - _, err := c.ConvertRequest(context.Background(), nil, &dto.GeneralOpenAIRequest{}) - if err == nil { - t.Fatal("want error for empty messages") - } -} - -func TestOpenAIToDifyRequestConverter_InvalidType(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - if _, err := c.ConvertRequest(context.Background(), nil, "bad"); err == nil { - t.Fatal("want error for wrong type") - } -} - -func TestOpenAIToDifyRequestConverter_MultimodalText(t *testing.T) { - c := &OpenAIToDifyRequestConverter{} - req := &dto.GeneralOpenAIRequest{ - Messages: []dto.Message{{ - Role: "user", - Content: []any{ - map[string]any{"type": "text", "text": "a"}, - map[string]any{"type": "text", "text": "b"}, - map[string]any{"type": "image_url", "image_url": map[string]any{"url": "x"}}, - }, - }}, - } - res, err := c.ConvertRequest(context.Background(), nil, req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - d := res.(*dto.DifyRequest) - if !strings.Contains(d.Query, "a b") { - t.Errorf("multimodal text not joined: %q", d.Query) - } -} - -// ---------- 非流式响应转换 ---------- - -func TestDifyToOpenAIResponseConverter_Basic(t *testing.T) { - c := &DifyToOpenAIResponseConverter{} - resp := &dto.DifyBlockingResponse{ - Answer: "Hello world", - Metadata: dto.DifyMeta{Usage: dto.DifyUsage{ - PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8, - }}, - } - res, err := c.ConvertResponse(context.Background(), nil, resp) - if err != nil { - t.Fatalf("ConvertResponse: %v", err) - } - o := res.(*dto.ChatCompletionResponse) - if len(o.Choices) != 1 || o.Choices[0].Message.Content != "Hello world" { - t.Errorf("unexpected response: %+v", o) - } - if o.Usage.TotalTokens != 8 { - t.Errorf("Usage.TotalTokens = %d, want 8", o.Usage.TotalTokens) - } -} - -func TestDifyToOpenAIResponseConverter_EstimatedUsage(t *testing.T) { - c := &DifyToOpenAIResponseConverter{} - resp := &dto.DifyBlockingResponse{Answer: "12345678"} // 8 chars → 2 tokens - res, _ := c.ConvertResponse(context.Background(), nil, resp) - o := res.(*dto.ChatCompletionResponse) - if o.Usage.CompletionTokens != 2 { - t.Errorf("estimated CompletionTokens = %d, want 2", o.Usage.CompletionTokens) - } -} - -func TestDifyToOpenAIResponseConverter_InvalidType(t *testing.T) { - c := &DifyToOpenAIResponseConverter{} - if _, err := c.ConvertResponse(context.Background(), nil, "bad"); err == nil { - t.Fatal("want error for wrong type") - } -} - -// ---------- 流式响应转换 ---------- - -func collectDifyStream(t *testing.T, sse string) []*dto.ChatCompletionStreamResponse { - t.Helper() - c := &DifyToOpenAIStreamConverter{} - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, bytes.NewReader([]byte(sse)), func(chunk any) error { - if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { - chunks = append(chunks, sc) - } - return nil - }) - if err != nil { - t.Fatalf("ConvertStreamResponse: %v", err) - } - return chunks -} - -func TestDifyToOpenAIStreamConverter_Basic(t *testing.T) { - sse := strings.Join([]string{ - `data: {"event":"message","answer":"Hello"}`, - `data: {"event":"message","answer":" world"}`, - `data: {"event":"message_end","metadata":{"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}}`, - "", - }, "\n") - chunks := collectDifyStream(t, sse) - // 期望:role chunk + 2 个内容 chunk + finish chunk(带 usage) - if len(chunks) != 4 { - t.Fatalf("chunks count = %d, want 4", len(chunks)) - } - // 第一个是 role chunk - if chunks[0].Choices[0].Delta.Role != "assistant" { - t.Errorf("first chunk role = %q", chunks[0].Choices[0].Delta.Role) - } - // 内容拼接 - if chunks[1].Choices[0].Delta.Content != "Hello" || chunks[2].Choices[0].Delta.Content != " world" { - t.Errorf("content chunks: %q, %q", chunks[1].Choices[0].Delta.Content, chunks[2].Choices[0].Delta.Content) - } - // 最后一个 finish chunk 带 finish_reason + usage - last := chunks[3] - if last.Choices[0].FinishReason == nil || *last.Choices[0].FinishReason != "stop" { - t.Errorf("finish reason: %v", last.Choices[0].FinishReason) - } - if last.Usage == nil || last.Usage.TotalTokens != 5 { - t.Errorf("usage: %v", last.Usage) - } -} - -func TestDifyToOpenAIStreamConverter_Error(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - sse := `data: {"event":"error"}` - err := c.ConvertStreamResponse(context.Background(), nil, bytes.NewReader([]byte(sse)), func(any) error { return nil }) - if err == nil { - t.Fatal("want error for dify error event") - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/dify_to_openai_response.go b/relaykit/relayconvert/internal/dify_chat/dify_to_openai_response.go deleted file mode 100644 index 5651bbe..0000000 --- a/relaykit/relayconvert/internal/dify_chat/dify_to_openai_response.go +++ /dev/null @@ -1,82 +0,0 @@ -package dify_chat - -import ( - "context" - "fmt" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// DifyToOpenAIResponseConverter 将 Dify blocking 响应转换为 OpenAI Chat Completions 响应。 -type DifyToOpenAIResponseConverter struct{} - -func (c *DifyToOpenAIResponseConverter) ID() string { - return relayconvert.ResponseConverterDifyChatToOAIChat -} - -func (c *DifyToOpenAIResponseConverter) From() types.RelayFormat { - return types.RelayFormatDify -} - -func (c *DifyToOpenAIResponseConverter) To() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *DifyToOpenAIResponseConverter) Quality() relayconvert.ResponseConverterQuality { - return relayconvert.ResponseConverterQualityFair -} - -// ConvertResponse 将 Dify blocking 响应转换为 OpenAI 非流式响应。 -// 注意:方法签名返回 (any, error),注册时由 register 包适配闭包补 nil Usage。 -func (c *DifyToOpenAIResponseConverter) ConvertResponse( - ctx context.Context, - info convmeta.Meta, - response any, -) (any, error) { - difyResp, ok := response.(*dto.DifyBlockingResponse) - if !ok { - return nil, fmt.Errorf("expected *dto.DifyBlockingResponse, got %T", response) - } - - modelName := "" - if info != nil { - modelName = info.GetOriginModelName() - } - - usage := dto.UsageWithDetails{ - PromptTokens: difyResp.Metadata.Usage.PromptTokens, - CompletionTokens: difyResp.Metadata.Usage.CompletionTokens, - TotalTokens: difyResp.Metadata.Usage.TotalTokens, - } - // Dify 未返回用量时,用 Answer 长度粗估 completion tokens - if usage.TotalTokens == 0 { - usage.CompletionTokens = estimateTokens(difyResp.Answer) - usage.TotalTokens = usage.CompletionTokens - } - - openaiResp := &dto.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-%d", getCurrentTimestamp()), - Object: "chat.completion", - Created: 0, - Model: modelName, - Choices: []dto.Choice{{ - Index: 0, - Message: dto.Message{ - Role: "assistant", - Content: difyResp.Answer, - }, - FinishReason: "stop", - }}, - Usage: usage, - } - - return openaiResp, nil -} - -// estimateTokens 粗略估算 token 数(4 字符 ≈ 1 token),与旧实现 helper.EstimateTokens 口径一致。 -func estimateTokens(s string) int { - return len(s) / 4 -} diff --git a/relaykit/relayconvert/internal/dify_chat/dify_to_openai_stream.go b/relaykit/relayconvert/internal/dify_chat/dify_to_openai_stream.go deleted file mode 100644 index ede6057..0000000 --- a/relaykit/relayconvert/internal/dify_chat/dify_to_openai_stream.go +++ /dev/null @@ -1,165 +0,0 @@ -package dify_chat - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// DifyToOpenAIStreamConverter 将 Dify streaming SSE 转换为 OpenAI Chat Completions SSE。 -type DifyToOpenAIStreamConverter struct{} - -func (c *DifyToOpenAIStreamConverter) ID() string { - return relayconvert.ResponseConverterDifyChatToOAIChatStream -} - -func (c *DifyToOpenAIStreamConverter) From() types.RelayFormat { - return types.RelayFormatDify -} - -func (c *DifyToOpenAIStreamConverter) To() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *DifyToOpenAIStreamConverter) Quality() relayconvert.ResponseConverterQuality { - return relayconvert.ResponseConverterQualityFair -} - -// ConvertStreamResponse 将 Dify SSE 流转换为 OpenAI SSE 流。 -// Dify SSE 帧形如:data: {"event":"message","answer":"chunk"} / {"event":"message_end",...} -func (c *DifyToOpenAIStreamConverter) ConvertStreamResponse( - ctx context.Context, - info convmeta.Meta, - reader io.Reader, - chunkWriter func(chunk any) error, -) error { - scanner := bufio.NewScanner(reader) - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 10*1024*1024) - - responseID := fmt.Sprintf("chatcmpl-%d", getCurrentTimestamp()) - - modelName := "" - if info != nil { - modelName = info.GetOriginModelName() - } - - newChunk := func(delta dto.Message, finishReason *string, usage *dto.UsageWithDetails) *dto.ChatCompletionStreamResponse { - m := modelName - if m == "" { - m = "dify" - } - chunk := &dto.ChatCompletionStreamResponse{ - ID: responseID, - Object: "chat.completion.chunk", - Created: 0, - Model: m, - Choices: []dto.StreamChoice{{ - Index: 0, - Delta: delta, - }}, - } - if finishReason != nil { - chunk.Choices[0].FinishReason = finishReason - } - if usage != nil { - chunk.Usage = usage - } - return chunk - } - - roleChunkSent := false - var capturedUsage dto.UsageWithDetails - - for scanner.Scan() { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - line := scanner.Text() - if line == "" || !strings.HasPrefix(line, "data:") { - continue - } - - data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) - if data == "" || data == "[DONE]" { - continue - } - - var event dto.DifyStreamEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - continue - } - - switch event.Event { - case "message": - if event.Answer == "" { - continue - } - // 首个内容 chunk 前补一个 role chunk - if !roleChunkSent { - emptyContent := "" - if err := chunkWriter(newChunk(dto.Message{Role: "assistant", Content: &emptyContent}, nil, nil)); err != nil { - return err - } - roleChunkSent = true - } - if err := chunkWriter(newChunk(dto.Message{Content: event.Answer}, nil, nil)); err != nil { - return err - } - - case "message_end": - capturedUsage = dto.UsageWithDetails{ - PromptTokens: event.Metadata.Usage.PromptTokens, - CompletionTokens: event.Metadata.Usage.CompletionTokens, - TotalTokens: event.Metadata.Usage.TotalTokens, - } - reason := "stop" - var usagePtr *dto.UsageWithDetails - if capturedUsage.TotalTokens > 0 || capturedUsage.PromptTokens > 0 || capturedUsage.CompletionTokens > 0 { - u := capturedUsage - usagePtr = &u - } - if err := chunkWriter(newChunk(dto.Message{}, &reason, usagePtr)); err != nil { - return err - } - return nil - - case "error": - return fmt.Errorf("dify stream error: %s", data) - } - } - - // 未收到 message_end(流异常结束):补发一个终止 chunk - if !roleChunkSent { - // 全程无内容,先补 role chunk - emptyContent := "" - if err := chunkWriter(newChunk(dto.Message{Role: "assistant", Content: &emptyContent}, nil, nil)); err != nil { - return err - } - } - reason := "stop" - var usagePtr *dto.UsageWithDetails - if capturedUsage.TotalTokens > 0 || capturedUsage.PromptTokens > 0 || capturedUsage.CompletionTokens > 0 { - u := capturedUsage - usagePtr = &u - } - if err := chunkWriter(newChunk(dto.Message{}, &reason, usagePtr)); err != nil { - return err - } - - if err := scanner.Err(); err != nil && err != io.EOF { - return fmt.Errorf("stream scanner error: %w", err) - } - return nil -} diff --git a/relaykit/relayconvert/internal/dify_chat/golden/01_basic_oai_to_dify_request.json b/relaykit/relayconvert/internal/dify_chat/golden/01_basic_oai_to_dify_request.json deleted file mode 100644 index b1a32f4..0000000 --- a/relaykit/relayconvert/internal/dify_chat/golden/01_basic_oai_to_dify_request.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "basic_oai_to_dify_request", - "from": "openai", - "to": "dify", - "request": { - "messages": [ - { - "content": "You are a helpful assistant.", - "role": "system" - }, - { - "content": "Hello Dify!", - "role": "user" - } - ], - "model": "gpt-4" - }, - "expected_request": { - "inputs": {}, - "query": "System: You are a helpful assistant.\nUser: Hello Dify!", - "response_mode": "blocking", - "user": "relay-user" - } -} \ No newline at end of file diff --git a/relaykit/relayconvert/internal/dify_chat/golden/02_basic_dify_to_oai_response.json b/relaykit/relayconvert/internal/dify_chat/golden/02_basic_dify_to_oai_response.json deleted file mode 100644 index ba76925..0000000 --- a/relaykit/relayconvert/internal/dify_chat/golden/02_basic_dify_to_oai_response.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "basic_dify_to_oai_response", - "from": "dify", - "to": "openai", - "response": { - "answer": "Hello from Dify!", - "conversation_id": "c1", - "message_id": "m1", - "metadata": { - "usage": { - "completion_tokens": 4, - "prompt_tokens": 6, - "total_tokens": 10 - } - } - }, - "expected_response": { - "id": "chatcmpl-1700000000", - "object": "chat.completion", - "created": 0, - "model": "gpt-4", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello from Dify!" - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 6, - "completion_tokens": 4, - "total_tokens": 10 - } - } -} \ No newline at end of file diff --git a/relaykit/relayconvert/internal/dify_chat/golden/03_basic_dify_to_oai_stream.json b/relaykit/relayconvert/internal/dify_chat/golden/03_basic_dify_to_oai_stream.json deleted file mode 100644 index 61fa2a1..0000000 --- a/relaykit/relayconvert/internal/dify_chat/golden/03_basic_dify_to_oai_stream.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "name": "basic_dify_to_oai_stream", - "from": "dify", - "to": "openai", - "stream_data": "data: {\"event\":\"message\",\"answer\":\"Hello \"}\ndata: {\"event\":\"message\",\"answer\":\"world\"}\ndata: {\"event\":\"message_end\",\"metadata\":{\"usage\":{\"total_tokens\":5,\"prompt_tokens\":2,\"completion_tokens\":3}}}\n", - "expected_stream_chunks": [ - { - "id": "chatcmpl-1700000000", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-4", - "choices": [ - { - "index": 0, - "delta": { - "role": "assistant", - "content": "" - }, - "finish_reason": null - } - ], - "usage": null - }, - { - "id": "chatcmpl-1700000000", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-4", - "choices": [ - { - "index": 0, - "delta": { - "role": "", - "content": "Hello " - }, - "finish_reason": null - } - ], - "usage": null - }, - { - "id": "chatcmpl-1700000000", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-4", - "choices": [ - { - "index": 0, - "delta": { - "role": "", - "content": "world" - }, - "finish_reason": null - } - ], - "usage": null - }, - { - "id": "chatcmpl-1700000000", - "object": "chat.completion.chunk", - "created": 0, - "model": "gpt-4", - "choices": [ - { - "index": 0, - "delta": { - "role": "", - "content": null - }, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 2, - "completion_tokens": 3, - "total_tokens": 5 - } - } - ] -} \ No newline at end of file diff --git a/relaykit/relayconvert/internal/dify_chat/golden_test.go b/relaykit/relayconvert/internal/dify_chat/golden_test.go deleted file mode 100644 index e81dc84..0000000 --- a/relaykit/relayconvert/internal/dify_chat/golden_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package dify_chat - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/relayconvert/internal/goldentest" - "github.com/qianfree/team-api/relaykit/relayconvert/kitutil" - "github.com/qianfree/team-api/relaykit/types" -) - -func difyGoldenMeta() *convmeta.Values { - return &convmeta.Values{ChannelMetaAttached: true, OriginModelName: "gpt-4", UpstreamModelName: "dify-bot", IsStream: false} -} - -func TestGolden_OpenAI_To_Dify_Request(t *testing.T) { - converter := &OpenAIToDifyRequestConverter{} - ctx := context.Background() - info := difyGoldenMeta() - - files, err := os.ReadDir("golden") - if err != nil { - t.Fatalf("read golden dir: %v", err) - } - for _, file := range files { - if !strings.HasSuffix(file.Name(), "_oai_to_dify_request.json") { - continue - } - t.Run(file.Name(), func(t *testing.T) { - tc := goldentest.Load(t, filepath.Join("golden", file.Name())) - if tc.From != types.RelayFormatOpenAI || tc.To != types.RelayFormatDify { - t.Skip("not an OpenAI→Dify request test") - } - req, err := kitutil.Any2Type[dto.GeneralOpenAIRequest](tc.Request) - if err != nil { - t.Fatalf("map→OpenAI request: %v", err) - } - result, err := converter.ConvertRequest(ctx, info, &req) - if err != nil { - t.Fatalf("ConvertRequest: %v", err) - } - if *goldentest.Update { - tc.ExpectedRequest = result - goldentest.Save(t, filepath.Join("golden", file.Name()), tc) - return - } - if !goldentest.Equal(result, tc.ExpectedRequest) { - t.Errorf("result does not match golden\nGot: %+v\nWant: %+v", result, tc.ExpectedRequest) - } - }) - } -} - -func TestGolden_Dify_To_OpenAI_Response(t *testing.T) { - converter := &DifyToOpenAIResponseConverter{} - ctx := context.Background() - info := difyGoldenMeta() - - files, err := os.ReadDir("golden") - if err != nil { - t.Fatalf("read golden dir: %v", err) - } - for _, file := range files { - if !strings.HasSuffix(file.Name(), "_dify_to_oai_response.json") { - continue - } - t.Run(file.Name(), func(t *testing.T) { - tc := goldentest.Load(t, filepath.Join("golden", file.Name())) - if tc.From != types.RelayFormatDify || tc.To != types.RelayFormatOpenAI { - t.Skip("not a Dify→OpenAI response test") - } - resp, err := kitutil.Any2Type[dto.DifyBlockingResponse](tc.Response) - if err != nil { - t.Fatalf("map→Dify response: %v", err) - } - result, err := converter.ConvertResponse(ctx, info, &resp) - if err != nil { - t.Fatalf("ConvertResponse: %v", err) - } - if *goldentest.Update { - tc.ExpectedResponse = result - goldentest.Save(t, filepath.Join("golden", file.Name()), tc) - return - } - // 响应 Created 为实时时间戳,比较时忽略。 - if !goldentest.EqualExcluding(result, tc.ExpectedResponse, "created") { - t.Errorf("result does not match golden\nGot: %+v\nWant: %+v", result, tc.ExpectedResponse) - } - }) - } -} - -func TestGolden_Dify_To_OpenAI_Stream(t *testing.T) { - converter := &DifyToOpenAIStreamConverter{} - ctx := context.Background() - info := &convmeta.Values{ChannelMetaAttached: true, OriginModelName: "gpt-4", IsStream: true} - - files, err := os.ReadDir("golden") - if err != nil { - t.Fatalf("read golden dir: %v", err) - } - for _, file := range files { - if !strings.HasSuffix(file.Name(), "_dify_to_oai_stream.json") { - continue - } - t.Run(file.Name(), func(t *testing.T) { - tc := goldentest.Load(t, filepath.Join("golden", file.Name())) - if tc.From != types.RelayFormatDify || tc.To != types.RelayFormatOpenAI { - t.Skip("not a Dify→OpenAI stream test") - } - var chunks []any - chunkWriter := func(chunk any) error { chunks = append(chunks, chunk); return nil } - if err := converter.ConvertStreamResponse(ctx, info, strings.NewReader(tc.StreamData), chunkWriter); err != nil { - t.Fatalf("ConvertStreamResponse: %v", err) - } - if *goldentest.Update { - tc.ExpectedStreamChunks = chunks - goldentest.Save(t, filepath.Join("golden", file.Name()), tc) - return - } - // chunk 的 id 为 chatcmpl-(实时),忽略 id/created 保持稳定。 - if !goldentest.EqualChunksExcluding(chunks, tc.ExpectedStreamChunks, "id") { - t.Errorf("stream chunks do not match golden\ngot=%+v\nwant=%+v", chunks, tc.ExpectedStreamChunks) - } - }) - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/helper.go b/relaykit/relayconvert/internal/dify_chat/helper.go deleted file mode 100644 index 164fcbd..0000000 --- a/relaykit/relayconvert/internal/dify_chat/helper.go +++ /dev/null @@ -1,7 +0,0 @@ -package dify_chat - -// getCurrentTimestamp 返回固定时间戳,保证 golden 测试稳定(与 oai_gemini 一致)。 -// 生产侧响应 ID 的真实化待后续灰度对齐。 -func getCurrentTimestamp() int64 { - return 1700000000 -} diff --git a/relaykit/relayconvert/internal/dify_chat/metadata_test.go b/relaykit/relayconvert/internal/dify_chat/metadata_test.go deleted file mode 100644 index 23e5d2d..0000000 --- a/relaykit/relayconvert/internal/dify_chat/metadata_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package dify_chat - -import ( - "testing" - - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/types" -) - -// TestConverterMetadata 覆盖响应/流式转换器的 ID/From/To/Quality 元数据方法。 -func TestConverterMetadata(t *testing.T) { - resp := &DifyToOpenAIResponseConverter{} - if resp.ID() != relayconvert.ResponseConverterDifyChatToOAIChat { - t.Errorf("resp ID = %q", resp.ID()) - } - if resp.From() != types.RelayFormatDify || resp.To() != types.RelayFormatOpenAI { - t.Errorf("resp From/To = %q/%q", resp.From(), resp.To()) - } - if resp.Quality() != relayconvert.ResponseConverterQualityFair { - t.Errorf("resp Quality = %q", resp.Quality()) - } - - stream := &DifyToOpenAIStreamConverter{} - if stream.ID() != relayconvert.ResponseConverterDifyChatToOAIChatStream { - t.Errorf("stream ID = %q", stream.ID()) - } - if stream.From() != types.RelayFormatDify || stream.To() != types.RelayFormatOpenAI { - t.Errorf("stream From/To = %q/%q", stream.From(), stream.To()) - } - if stream.Quality() != relayconvert.ResponseConverterQualityFair { - t.Errorf("stream Quality = %q", stream.Quality()) - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/openai_to_dify_request.go b/relaykit/relayconvert/internal/dify_chat/openai_to_dify_request.go deleted file mode 100644 index 8fb0b61..0000000 --- a/relaykit/relayconvert/internal/dify_chat/openai_to_dify_request.go +++ /dev/null @@ -1,132 +0,0 @@ -// Package dify_chat 实现 OpenAI Chat Completions ↔ Dify chat-messages 双向转换器。 -// -// Dify 是开源 LLM 应用开发平台,chat-messages 端点只接受单个 Query 字符串, -// 因此将 OpenAI 的多轮消息拼接为带角色前缀的 query。响应侧把 Dify 的 -// blocking JSON / streaming SSE 转回 OpenAI Chat Completions 格式。 -package dify_chat - -import ( - "context" - "fmt" - "strings" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/types" -) - -// OpenAIToDifyRequestConverter 将 OpenAI Chat Completions 请求转换为 Dify 请求。 -type OpenAIToDifyRequestConverter struct{} - -func (c *OpenAIToDifyRequestConverter) ID() string { - return relayconvert.ConverterOpenAIChatToDify -} - -func (c *OpenAIToDifyRequestConverter) From() types.RelayFormat { - return types.RelayFormatOpenAI -} - -func (c *OpenAIToDifyRequestConverter) To() types.RelayFormat { - return types.RelayFormatDify -} - -func (c *OpenAIToDifyRequestConverter) Quality() relayconvert.RequestConverterQuality { - return relayconvert.RequestConverterQualityFair -} - -// ConvertRequest 将 OpenAI 请求转换为 Dify chat-messages 请求。 -func (c *OpenAIToDifyRequestConverter) ConvertRequest( - ctx context.Context, - info convmeta.Meta, - request any, -) (any, error) { - openaiReq, ok := request.(*dto.GeneralOpenAIRequest) - if !ok { - return nil, fmt.Errorf("expected *dto.GeneralOpenAIRequest, got %T", request) - } - - // 拼接所有消息为 query 字符串 - query := flattenMessages(openaiReq.Messages) - if query == "" { - return nil, fmt.Errorf("no messages found in request") - } - - // response_mode 由是否流式决定 - responseMode := "blocking" - if info != nil && info.GetIsStream() { - responseMode = "streaming" - } - - // 用户标识 - user := openaiReq.User - if user == "" { - user = "relay-user" - } - - difyReq := &dto.DifyRequest{ - Inputs: map[string]any{}, - Query: query, - ResponseMode: responseMode, - User: user, - } - - return difyReq, nil -} - -// flattenMessages 将 OpenAI 消息列表拼接为带角色前缀的字符串。 -// 格式: "System: ...\nUser: ...\nAssistant: ..." -func flattenMessages(messages []dto.Message) string { - if len(messages) == 0 { - return "" - } - - var parts []string - for _, msg := range messages { - text := extractTextContent(msg.Content) - if text == "" { - continue - } - prefix := capitalizeFirst(msg.Role) - parts = append(parts, prefix+": "+text) - } - - return strings.Join(parts, "\n") -} - -// capitalizeFirst 将字符串首字母大写(角色名均为单个 ASCII 单词,等价于 strings.Title 的行为)。 -// 不使用已废弃的 strings.Title,避免 Unicode 标点边界问题。 -func capitalizeFirst(s string) string { - if s == "" { - return s - } - if c := s[0]; c >= 'a' && c <= 'z' { - return string(c-32) + s[1:] - } - return s -} - -// extractTextContent 从消息内容中提取文本。 -// Content 可以是 string 或 []any(多模态,JSON 解析后的原始数组)。 -func extractTextContent(content any) string { - switch c := content.(type) { - case string: - return c - case []any: - var texts []string - for _, part := range c { - m, ok := part.(map[string]any) - if !ok { - continue - } - if t, _ := m["type"].(string); t == "text" { - if text, ok := m["text"].(string); ok { - texts = append(texts, text) - } - } - } - return strings.Join(texts, " ") - default: - return "" - } -} diff --git a/relaykit/relayconvert/internal/dify_chat/stream_edge_test.go b/relaykit/relayconvert/internal/dify_chat/stream_edge_test.go deleted file mode 100644 index 710fe0d..0000000 --- a/relaykit/relayconvert/internal/dify_chat/stream_edge_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package dify_chat - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/stretchr/testify/assert" -) - -func collectChunks(out *[]*dto.ChatCompletionStreamResponse) func(any) error { - return func(chunk any) error { - if sc, ok := chunk.(*dto.ChatCompletionStreamResponse); ok { - *out = append(*out, sc) - } - return nil - } -} - -func TestDifyStream_ErrorEvent(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - sse := "data: {\"event\":\"error\"}\n" - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(nil)) - assert.Error(t, err) - assert.Contains(t, err.Error(), "dify stream error") -} - -func TestDifyStream_ContextCanceled(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - sse := "data: {\"event\":\"message\",\"answer\":\"hi\"}\n" - err := c.ConvertStreamResponse(ctx, nil, strings.NewReader(sse), collectChunks(nil)) - assert.ErrorIs(t, err, context.Canceled) -} - -func TestDifyStream_ChunkWriterError(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - sse := "data: {\"event\":\"message\",\"answer\":\"hi\"}\n" - boomer := func(any) error { return errors.New("boom") } - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), boomer) - assert.ErrorContains(t, err, "boom") -} - -func TestDifyStream_MessageEndWithUsage(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - sse := "data: {\"event\":\"message\",\"answer\":\"hi\"}\n" + - `data: {"event":"message_end","metadata":{"usage":{"total_tokens":8,"prompt_tokens":5,"completion_tokens":3}}}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - // role + content + finish(带 usage) - if assert.Len(t, chunks, 3) { - assert.NotNil(t, chunks[2].Usage) - assert.Equal(t, 8, chunks[2].Usage.TotalTokens) - } -} - -func TestDifyStream_MessageEndWithoutUsage(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - sse := "data: {\"event\":\"message\",\"answer\":\"hi\"}\n" + - `data: {"event":"message_end","metadata":{"usage":{}}}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - if assert.Len(t, chunks, 3) { - assert.Nil(t, chunks[2].Usage) // 全零 usage 不附带 - } -} - -func TestDifyStream_AbnormalEOF_NoMessage(t *testing.T) { - // 全程无 message:结尾补 role + finish - c := &DifyToOpenAIStreamConverter{} - sse := "data: {\"event\":\"ping\"}\n" // 未知事件,被忽略 - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - if assert.Len(t, chunks, 2) { - assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role) - assert.NotNil(t, chunks[1].Choices[0].FinishReason) - } -} - -func TestDifyStream_DoneMarkerAndEmptySkipped(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - // 空行 / [DONE] / 空 data 应被跳过;仅 message_end 收尾 - sse := "\ndata: [DONE]\ndata: \n" + - `data: {"event":"message","answer":"hi"}` + "\n" + - `data: {"event":"message_end","metadata":{"usage":{"total_tokens":2}}}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - err := c.ConvertStreamResponse(context.Background(), nil, strings.NewReader(sse), collectChunks(&chunks)) - assert.NoError(t, err) - assert.Len(t, chunks, 3) // role + content + finish -} - -func TestDifyStream_OriginModelName(t *testing.T) { - c := &DifyToOpenAIStreamConverter{} - info := &convmeta.Values{OriginModelName: "my-model", IsStream: true} - sse := "data: {\"event\":\"message\",\"answer\":\"hi\"}\n" + - `data: {"event":"message_end","metadata":{"usage":{}}}` + "\n" - var chunks []*dto.ChatCompletionStreamResponse - _ = c.ConvertStreamResponse(context.Background(), info, strings.NewReader(sse), collectChunks(&chunks)) - if assert.NotEmpty(t, chunks) { - assert.Equal(t, "my-model", chunks[0].Model) - } -} diff --git a/relaykit/relayconvert/internal/native_responses/claude_to_responses_response.go b/relaykit/relayconvert/internal/native_responses/claude_to_responses_response.go new file mode 100644 index 0000000..ccb93a3 --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_response.go @@ -0,0 +1,129 @@ +package native_responses + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// ClaudeToResponsesResponseConverter 将 Claude Messages 非流式响应转换为 OpenAI Responses 响应。 +// 文本块 → message 项,tool_use 块 → function_call 项; +// 思考内容无 Responses 非流式对应物,跳过(流式侧以 reasoning summary 事件透出)。 +type ClaudeToResponsesResponseConverter struct{} + +func (c *ClaudeToResponsesResponseConverter) ID() string { + return relayconvert.ResponseConverterClaudeMessagesToOAIResponses +} + +func (c *ClaudeToResponsesResponseConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToResponsesResponseConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *ClaudeToResponsesResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 *dto.ClaudeResponse 转换为 Responses 响应对象(map 构造,与旧桥一致)。 +func (c *ClaudeToResponsesResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + claudeResp, ok := response.(*dto.ClaudeResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.ClaudeResponse, got %T", response) + } + + // 构建 output:thinking 块 → reasoning 项,文本块 → message 项,tool_use 块 → function_call 项 + var textParts []string + var thinkingParts []string + output := make([]map[string]any, 0) + for _, block := range claudeResp.Content { + switch block.Type { + case "text": + if block.Text != nil && *block.Text != "" { + textParts = append(textParts, *block.Text) + } + case "thinking", "redacted_thinking": + // Responses 非流式以 reasoning 输出项承载思考内容 + //(流式侧的 response.reasoning_summary_text.delta 只是它的增量表达)。 + // 跳过等于让推理模型的思考过程静默消失,且客户端看不出丢过东西。 + if block.Thinking != nil && *block.Thinking != "" { + thinkingParts = append(thinkingParts, *block.Thinking) + } + case "tool_use": + argsJSON, _ := json.Marshal(block.Input) + output = append(output, map[string]any{ + "type": "function_call", + "id": block.ID, + "call_id": block.ID, + "name": block.Name, + "arguments": string(argsJSON), + "status": "completed", + }) + case "server_tool_use": + // claude 服务端工具:仅 web_search 有 Responses 对应物(web_search_call), + // 其余服务端工具跳过 + if block.Name == "web_search" { + argsJSON, _ := json.Marshal(block.Input) + output = append(output, claudeWebSearchCallItem(block.ID, string(argsJSON))) + } + case "web_search_tool_result": + // 搜索结果块无 Responses 对应物——结果已内化为模型的文本回答与引用,跳过 + } + } + if len(textParts) > 0 { + msgItem := map[string]any{ + "type": "message", + "id": fmt.Sprintf("msg_%s", claudeResp.ID), + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": strings.Join(textParts, "\n"), + "annotations": []any{}, + }}, + } + output = append([]map[string]any{msgItem}, output...) + } + // reasoning 项排在最前(与 Responses API 的真实输出顺序一致:思考先于回答) + if len(thinkingParts) > 0 { + reasoningItem := map[string]any{ + "type": "reasoning", + "id": fmt.Sprintf("rs_%s", claudeResp.ID), + "summary": []map[string]any{{ + "type": "summary_text", + "text": strings.Join(thinkingParts, ""), + }}, + } + output = append([]map[string]any{reasoningItem}, output...) + } + + modelName := claudeResp.Model + if modelName == "" || isModelMapped(info) { + modelName = originModelName(info) + } + respID := fmt.Sprintf("resp_%s", claudeResp.ID) + if claudeResp.ID == "" { + respID = fmt.Sprintf("resp_%d", time.Now().UnixNano()) + } + createdAt := int(time.Now().Unix()) + completedAt := createdAt + + // 客户端可见 usage 用 OpenAI 语义(input 含缓存,cached 为子集) + visibleUsage := claudeVisibleUsage(claudeResp.Usage) + + return buildResponsesObjectMap(respID, createdAt, "completed", modelName, output, + buildResponsesUsageMap(visibleUsage), &completedAt, info), nil +} diff --git a/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go b/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go new file mode 100644 index 0000000..24baf30 --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go @@ -0,0 +1,446 @@ +package native_responses + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// claudeToolCallState Responses 桥接中聚合中的工具调用(Claude tool_use 块) +type claudeToolCallState struct { + id string + name string + args strings.Builder +} + +// ClaudeToResponsesStreamConverter 将 Claude SSE 流转换为 OpenAI Responses 事件流。 +// 事件序列与 chat→responses 桥接保持一致,每个事件以 StreamEvent{Event: 事件名} 输出。 +type ClaudeToResponsesStreamConverter struct{} + +func (c *ClaudeToResponsesStreamConverter) ID() string { + return relayconvert.ResponseConverterClaudeMessagesToOAIResponsesStream +} + +func (c *ClaudeToResponsesStreamConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToResponsesStreamConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *ClaudeToResponsesStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Claude SSE 事件流转换为 Responses 事件帧序列。 +func (c *ClaudeToResponsesStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + respID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) + msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) + createdAt := int(time.Now().Unix()) + modelName := originModelName(info) + + var usage dto.ClaudeUsage + var textBuf strings.Builder + sentCreated := false + sentTextDone := false + sentCompleted := false + outputIndex := 0 + contentIndex := 0 + toolCalls := make([]*claudeToolCallState, 0) // 有序聚合,completed 的 output 数组按此顺序 + toolIndexByID := make(map[string]int) // callID → output_index + var currentTool *claudeToolCallState // 正在接收参数增量的工具调用 + // 服务端工具(claude 的 web_search):映射为 Responses 的 web_search_call 项。 + // 搜索结果块(web_search_tool_result)无 Responses 对应物——结果已内化为模型的 + // 文本回答与引用,跳过即可 + serverToolCalls := make([]*claudeToolCallState, 0) + serverToolIndexByID := make(map[string]int) + var currentServerTool *claudeToolCallState + + // emitEvent 发出一个 Responses 格式事件帧 + emitEvent := func(eventType string, data map[string]any, u *dto.UsageWithDetails) error { + return chunkWriter(&relayconvert.StreamEvent{Event: eventType, Data: data, Usage: u}) + } + + // closeTextPart 关闭文本 content part(进入工具调用或流结束时调用) + closeTextPart := func() error { + if sentTextDone { + return nil + } + finishedText := textBuf.String() + if err := emitEvent("response.output_text.done", map[string]any{ + "type": "response.output_text.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "text": finishedText, + }, nil); err != nil { + return err + } + if err := emitEvent("response.content_part.done", map[string]any{ + "type": "response.content_part.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }}, + }, + }, nil); err != nil { + return err + } + sentTextDone = true + outputIndex++ + return nil + } + + // finish 发送每个工具调用的收尾事件 + response.completed(随帧携带计费用量) + finish := func() error { + if sentCompleted { + return nil + } + if err := closeTextPart(); err != nil { + return err + } + + // 输出数组:文本消息(closeTextPart 已置 sentTextDone,此处等价于有文本才保留, + // 与 chat→responses 桥接的 finalOutput 构建口径一致) + finalOutput := make([]map[string]any, 0) + if textBuf.Len() > 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": textBuf.String(), + "annotations": []any{}, + }}, + }) + } + + for _, tc := range toolCalls { + if err := emitEvent("response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", + "item_id": tc.id, + "output_index": toolIndexByID[tc.id], + "arguments": tc.args.String(), + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": toolIndexByID[tc.id], + "item": map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "arguments": tc.args.String(), + "status": "completed", + }, + }, nil); err != nil { + return err + } + finalOutput = append(finalOutput, map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "arguments": tc.args.String(), + "status": "completed", + }) + } + + // 服务端 web_search 调用收尾:web_search_call 项(query 从聚合参数解析) + for _, st := range serverToolCalls { + item := claudeWebSearchCallItem(st.id, st.args.String()) + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": serverToolIndexByID[st.id], + "item": item, + }, nil); err != nil { + return err + } + finalOutput = append(finalOutput, item) + } + + // 客户端可见 usage 用 OpenAI 语义(input 含缓存,cached 为子集); + // completed 帧同时携带 Claude 计费口径的用量(input 不含缓存、cache 明细独立), + // 输出缺失时的估算兜底(EstimateStreamOutputTokens)由宿主桥接层处理 + completedAt := int(time.Now().Unix()) + if err := emitEvent("response.completed", map[string]any{ + "type": "response.completed", + "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, + finalOutput, buildResponsesUsageMap(claudeVisibleUsage(&usage)), &completedAt, info), + }, claudeBillingUsage(&usage)); err != nil { + return err + } + sentCompleted = true + return nil + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + // 流中断:计费兜底(输出估算、输入补齐)由宿主桥接层处理 + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" || !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + + var event dto.ClaudeResponse + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + switch event.Type { + case "message_start": + if event.Message != nil { + if event.Message.Model != "" && !isModelMapped(info) { + modelName = event.Message.Model + } + if event.Message.Usage != nil { + usage = *event.Message.Usage + } + } + if sentCreated { + continue + } + if err := emitEvent("response.created", map[string]any{ + "type": "response.created", + "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, info), + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "in_progress", + "role": "assistant", + "content": []any{}, + }, + }, nil); err != nil { + return err + } + if err := emitEvent("response.content_part.added", map[string]any{ + "type": "response.content_part.added", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": "", + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + sentCreated = true + + case "content_block_start": + if event.ContentBlock == nil { + continue + } + switch event.ContentBlock.Type { + case "tool_use": + // 先关闭文本 content part,再开 function_call 项 + if err := closeTextPart(); err != nil { + return err + } + tc := &claudeToolCallState{id: event.ContentBlock.ID, name: event.ContentBlock.Name} + toolCalls = append(toolCalls, tc) + toolIndexByID[tc.id] = outputIndex + currentTool = tc + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "status": "in_progress", + }, + }, nil); err != nil { + return err + } + outputIndex++ + case "server_tool_use": + // claude 服务端工具:仅 web_search 有 Responses 对应物(web_search_call), + // 其余服务端工具跳过。参数以 input_json_delta 聚合,收尾在 finish 统一冲刷 + if event.ContentBlock.Name != "web_search" { + continue + } + if err := closeTextPart(); err != nil { + return err + } + st := &claudeToolCallState{id: event.ContentBlock.ID, name: event.ContentBlock.Name} + serverToolCalls = append(serverToolCalls, st) + serverToolIndexByID[st.id] = outputIndex + currentServerTool = st + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "web_search_call", + "id": st.id, + "status": "in_progress", + }, + }, nil); err != nil { + return err + } + outputIndex++ + case "text", "thinking", "redacted_thinking": + // 文本/思考块:文本复用首个 content part,思考以 reasoning summary 事件透出 + } + + case "content_block_delta": + if event.Delta == nil { + continue + } + switch event.Delta.Type { + case "text_delta": + if event.Delta.Text != nil && *event.Delta.Text != "" { + textBuf.WriteString(*event.Delta.Text) + if err := emitEvent("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", + "item_id": msgID, + "output_index": 0, + "content_index": contentIndex, + "delta": *event.Delta.Text, + }, nil); err != nil { + return err + } + } + case "thinking_delta": + if event.Delta.Thinking != nil && *event.Delta.Thinking != "" { + if err := emitEvent("response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", + "item_id": msgID, + "output_index": 0, + "summary_index": 0, + "delta": *event.Delta.Thinking, + }, nil); err != nil { + return err + } + } + case "input_json_delta": + if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" && currentTool != nil { + currentTool.args.WriteString(*event.Delta.PartialJSON) + if err := emitEvent("response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", + "item_id": currentTool.id, + "output_index": toolIndexByID[currentTool.id], + "delta": *event.Delta.PartialJSON, + }, nil); err != nil { + return err + } + } else if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" && currentServerTool != nil { + // web_search_call 无参数增量事件形态,仅聚合(query 收尾时解析) + currentServerTool.args.WriteString(*event.Delta.PartialJSON) + } + case "signature_delta": + // 思考签名无 Responses 对应物,忽略 + } + + case "content_block_stop": + // 块级收尾统一延迟到 message_stop / finish,这里仅结束当前工具块的增量定向 + if currentTool != nil { + currentTool = nil + } + if currentServerTool != nil { + currentServerTool = nil + } + + case "message_delta": + if event.Usage != nil { + if event.Usage.InputTokens > 0 { + usage.InputTokens = event.Usage.InputTokens + } + usage.OutputTokens = event.Usage.OutputTokens + if event.Usage.CacheReadInputTokens > 0 { + usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens + } + if event.Usage.CacheCreationInputTokens > 0 { + usage.CacheCreationInputTokens = event.Usage.CacheCreationInputTokens + } + if event.Usage.CacheCreation != nil { + usage.CacheCreation = event.Usage.CacheCreation + } + } + + case "message_stop": + return finish() + + case "error": + // 上游 error 事件:不合成假成功的 response.completed,直接以错误返回; + // 错误体写出与已开场/未开场的分支处理由宿主桥接层完成 + errMsg := "claude stream error" + if event.Error != nil { + if b, err := json.Marshal(event.Error); err == nil { + errMsg = fmt.Sprintf("claude stream error: %s", string(b)) + } + } + return fmt.Errorf("%s", errMsg) + + case "ping": + // 保活事件,忽略 + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + return fmt.Errorf("stream scanner error: %w", err) + } + + // 上游未发 message_stop 即断流:仍合成 completed,避免客户端挂起 + return finish() +} diff --git a/relaykit/relayconvert/internal/native_responses/claude_to_responses_test.go b/relaykit/relayconvert/internal/native_responses/claude_to_responses_test.go new file mode 100644 index 0000000..a81c6ec --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_test.go @@ -0,0 +1,341 @@ +package native_responses + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// claudeUpstreamMeta 构造 Responses 入站(codex 等)+ Claude 上游渠道的 Meta +func claudeUpstreamMeta(isStream bool) *convmeta.Values { + return &convmeta.Values{ + OriginModelName: "glm-5.3", + UpstreamModelName: "glm-5.3", + ChannelMetaAttached: true, + IsStream: isStream, + } +} + +// runClaudeToResponsesStream 跑流式转换并收集 StreamEvent 序列 +func runClaudeToResponsesStream(t *testing.T, meta convmeta.Meta, upstream string) ([]*relayconvert.StreamEvent, error) { + t.Helper() + conv := &ClaudeToResponsesStreamConverter{} + var events []*relayconvert.StreamEvent + err := conv.ConvertStreamResponse(context.Background(), meta, strings.NewReader(upstream), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("chunk 类型应为 *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +// ===== 非流式 ===== + +// TestClaudeToResponsesResponse_Body Claude 非流式响应转换为 Responses 格式: +// 文本块 → message 项,tool_use 块 → function_call 项,usage 用 OpenAI 语义(input 含缓存)。 +func TestClaudeToResponsesResponse_Body(t *testing.T) { + text := "答案是42" + claudeResp := &dto.ClaudeResponse{ + ID: "msg_02", + Type: "message", + Role: "assistant", + Model: "glm-5.3", + Content: []dto.ClaudeContentBlock{ + {Type: "text", Text: &text}, + {Type: "tool_use", ID: "toolu_02", Name: "read", Input: map[string]any{"path": "a.go"}}, + }, + StopReason: "end_turn", + Usage: &dto.ClaudeUsage{ + InputTokens: 10, + CacheReadInputTokens: 4, + OutputTokens: 5, + }, + } + + conv := &ClaudeToResponsesResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), claudeUpstreamMeta(false), claudeResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + m, ok := res.(map[string]any) + if !ok { + t.Fatalf("返回类型应为 map[string]any, got %T", res) + } + + if m["object"] != "response" || m["status"] != "completed" { + t.Errorf("response envelope wrong: %v", m) + } + if m["id"] != "resp_msg_02" { + t.Errorf("id = %v, want resp_msg_02", m["id"]) + } + output, _ := m["output"].([]map[string]any) + if len(output) != 2 { + t.Fatalf("output = %v, want 2 items (message + function_call)", output) + } + msg := output[0] + if msg["type"] != "message" { + t.Errorf("output[0] = %v, want message", msg) + } + content := msg["content"].([]map[string]any) + if len(content) != 1 || content[0]["text"] != "答案是42" { + t.Errorf("message content 不对: %+v", content) + } + fn := output[1] + if fn["type"] != "function_call" || fn["name"] != "read" { + t.Errorf("output[1] = %v, want function_call read", fn) + } + if fn["id"] != "toolu_02" || fn["call_id"] != "toolu_02" { + t.Errorf("function_call id 应搬运 Claude tool_use.id: %+v", fn) + } + var args map[string]any + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments 应为 JSON 字符串: %v", err) + } + if args["path"] != "a.go" { + t.Errorf("参数丢失: %+v", args) + } + + // usage 用 OpenAI 语义:input 含缓存 + u, _ := m["usage"].(map[string]any) + if u["input_tokens"].(int) != 14 || u["output_tokens"].(int) != 5 { + t.Errorf("usage = %v, want input=14 output=5", u) + } + d, _ := u["input_tokens_details"].(map[string]any) + if d["cached_tokens"].(int) != 4 { + t.Errorf("input_tokens_details = %v, want cached_tokens=4", d) + } +} + +// TestClaudeToResponsesResponse_ThinkingSkipped 思考内容无 Responses 非流式对应物,跳过 +// TestClaudeToResponsesResponse_ThinkingBecomesReasoningItem Claude 的 thinking 块 +// 转换为 Responses 的 reasoning 输出项(排在 message 之前),而不是被丢弃或混进正文。 +// +// 本测试此前名为 ThinkingSkipped、断言 output 只有 1 项——把「思考内容静默丢失」 +// 固化成了期望。Responses 非流式本就有 reasoning 输出项形态(流式侧的 +// response.reasoning_summary_text.delta 只是它的增量表达),跳过是能力失恒。 +func TestClaudeToResponsesResponse_ThinkingBecomesReasoningItem(t *testing.T) { + thinking := "想一想" + text := "你好" + claudeResp := &dto.ClaudeResponse{ + ID: "msg_1", + Content: []dto.ClaudeContentBlock{ + {Type: "thinking", Thinking: &thinking}, + {Type: "redacted_thinking"}, + {Type: "text", Text: &text}, + }, + StopReason: "end_turn", + } + + conv := &ClaudeToResponsesResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), claudeUpstreamMeta(false), claudeResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + m := res.(map[string]any) + output := m["output"].([]map[string]any) + if len(output) != 2 { + t.Fatalf("output 项数 = %d, want 2(reasoning + message): %+v", len(output), output) + } + + // reasoning 项在最前,与 Responses API 的真实输出顺序一致 + if output[0]["type"] != "reasoning" { + t.Fatalf("首项应为 reasoning, got %v", output[0]["type"]) + } + summary := output[0]["summary"].([]map[string]any) + if len(summary) != 1 || summary[0]["type"] != "summary_text" || summary[0]["text"] != "想一想" { + t.Errorf("reasoning.summary 形态不符: %+v", summary) + } + + // 正文不得混入思考内容 + if output[1]["type"] != "message" { + t.Fatalf("次项应为 message, got %v", output[1]["type"]) + } + content := output[1]["content"].([]map[string]any) + if content[0]["text"] != "你好" { + t.Errorf("思考内容不得混进正文: %+v", content) + } +} + +// ===== 流式 ===== + +// TestClaudeToResponsesStream_TextAndToolCall +// Claude SSE(文本 + 工具调用)完整转换为 Responses 事件流: +// 事件序列、工具参数聚合、completed 的 usage 映射(OpenAI 语义)。 +func TestClaudeToResponsesStream_TextAndToolCall(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_01","model":"glm-5.3","usage":{"input_tokens":10,"output_tokens":1}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01","name":"shell","input":{}}}`, + ``, + `data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}`, + ``, + `data: {"type":"content_block_stop","index":1}`, + ``, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"input_tokens":10,"cache_read_input_tokens":4,"output_tokens":7}}`, + ``, + `data: {"type":"message_stop"}`, + ``, + }, "\n") + + events, err := runClaudeToResponsesStream(t, claudeUpstreamMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := responsesEventTypes(t, events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + // 工具调用前先收掉文本 part + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + if responsesDataOf(t, events[3])["delta"] != "你好" { + t.Errorf("text delta 不对: %+v", responsesDataOf(t, events[3])) + } + if responsesDataOf(t, events[8])["delta"] != `{"cmd":"ls"}` { + t.Errorf("tool arguments delta 不对: %+v", responsesDataOf(t, events[8])) + } + toolItem := responsesDataOf(t, events[7])["item"].(map[string]any) + if toolItem["name"] != "shell" || toolItem["id"] != "toolu_01" { + t.Errorf("function_call 项不对: %+v", toolItem) + } + + // completed 的 usage 用 OpenAI 语义:input 含缓存(10+4),cached_tokens 为子集 + completed := responsesDataOf(t, events[11])["response"].(map[string]any) + u := completed["usage"].(map[string]any) + if u["input_tokens"].(int) != 14 { + t.Errorf("completed usage input_tokens = %v, want 14 (input+cache_read)", u["input_tokens"]) + } + if u["input_tokens_details"].(map[string]any)["cached_tokens"].(int) != 4 { + t.Errorf("completed usage cached_tokens = %v, want 4", u["input_tokens_details"]) + } + if u["output_tokens"].(int) != 7 { + t.Errorf("completed usage output_tokens = %v, want 7", u["output_tokens"]) + } + + // completed 的 output 数组:message + function_call + out := completed["output"].([]map[string]any) + if len(out) != 2 || out[0]["type"] != "message" || out[1]["type"] != "function_call" { + t.Errorf("completed output 不对: %+v", out) + } + + // 计费用量随 completed 帧携带,按 Claude 口径(input 不含缓存,cache 明细独立) + if events[11].Usage == nil { + t.Fatal("completed 帧必须携带计费用量") + } + if events[11].Usage.PromptTokens != 10 || events[11].Usage.CompletionTokens != 7 { + t.Errorf("billing usage = %+v, want prompt=10 completion=7", events[11].Usage) + } + if events[11].Usage.PromptTokensDetails == nil || events[11].Usage.PromptTokensDetails.CachedTokens != 4 { + t.Errorf("billing usage cache detail = %+v, want cached_tokens=4", events[11].Usage.PromptTokensDetails) + } +} + +// TestClaudeToResponsesStream_UpstreamErrorBeforeEvents +// 首个事件即 error:返回错误且不合成假成功的 response.completed。 +func TestClaudeToResponsesStream_UpstreamErrorBeforeEvents(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`, + ``, + }, "\n") + + events, err := runClaudeToResponsesStream(t, claudeUpstreamMeta(true), upstream) + if err == nil { + t.Fatal("upstream error event should return an error, got nil") + } + if !strings.Contains(err.Error(), "overloaded_error") { + t.Errorf("错误信息应包含上游错误详情: %v", err) + } + for _, ev := range events { + if ev.Event == "response.completed" { + t.Errorf("上游错误不应合成 response.completed: %v", responsesEventTypes(t, events)) + } + } +} + +// TestClaudeToResponsesStream_UnfinishedStreamStillCompletes 上游未发 message_stop 即断流: +// 仍合成 completed,避免客户端挂起。 +func TestClaudeToResponsesStream_UnfinishedStreamStillCompletes(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_01","model":"glm-5.3","usage":{"input_tokens":10,"output_tokens":1}}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"半截"}}`, + ``, + }, "\n") + + events, err := runClaudeToResponsesStream(t, claudeUpstreamMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + got := responsesEventTypes(t, events) + if len(got) == 0 || got[len(got)-1] != "response.completed" { + t.Fatalf("必须以 response.completed 收尾, got %v", got) + } +} + +// TestClaudeToResponsesStream_ThinkingAsReasoningSummary 思考增量以 reasoning summary 事件透出 +func TestClaudeToResponsesStream_ThinkingAsReasoningSummary(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"type":"message_start","message":{"id":"msg_01","model":"glm-5.3","usage":{"input_tokens":5,"output_tokens":0}}}`, + ``, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"推理中"}}`, + ``, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig-1"}}`, + ``, + `data: {"type":"content_block_stop","index":0}`, + ``, + `data: {"type":"message_stop"}`, + ``, + }, "\n") + + events, err := runClaudeToResponsesStream(t, claudeUpstreamMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + got := responsesEventTypes(t, events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.reasoning_summary_text.delta", // signature_delta 无对应物被忽略 + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + if responsesDataOf(t, events[3])["delta"] != "推理中" { + t.Errorf("reasoning delta 不对: %+v", responsesDataOf(t, events[3])) + } +} diff --git a/relaykit/relayconvert/internal/native_responses/gemini_to_responses_response.go b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_response.go new file mode 100644 index 0000000..fd3221e --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_response.go @@ -0,0 +1,203 @@ +package native_responses + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// GeminiToResponsesResponseConverter 将 Gemini GenerateContent 非流式响应转换为 OpenAI Responses 响应。 +// +// 用量口径:Responses 的客户端可见 usage 与 Gemini 上游的计费口径**恰好一致** +// (都是 OpenAI 语义:input 含缓存、output 含思考),故两者共用 geminiUsageToDetails。 +// +// 协议落差: +// - Gemini 的 functionCall 未必带 id,Responses 的 function_call 必须有 call_id → 缺失时合成。 +// - 思考内容在 Responses 非流式响应里没有对应物(沿用 claude→responses 的口径跳过)。 +type GeminiToResponsesResponseConverter struct{} + +func (c *GeminiToResponsesResponseConverter) ID() string { + return relayconvert.ResponseConverterGeminiChatToOAIResponses +} + +func (c *GeminiToResponsesResponseConverter) From() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *GeminiToResponsesResponseConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *GeminiToResponsesResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 *dto.GeminiChatResponse 转换为 Responses 响应对象(map 构造,与旧桥一致)。 +// promptFeedback.blockReason 命中(Gemini 安全过滤)时返回错误。 +func (c *GeminiToResponsesResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + geminiResp, ok := response.(*dto.GeminiChatResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.GeminiChatResponse, got %T", response) + } + + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + return nil, fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) + } + + idBase := fmt.Sprintf("%d", time.Now().UnixNano()) + output := buildResponsesOutputFromGemini(geminiResp, idBase) + + modelName := geminiResp.ModelName + if modelName == "" || isModelMapped(info) { + modelName = originModelName(info) + } + createdAt := int(time.Now().Unix()) + completedAt := createdAt + + usage := geminiUsageToDetails(geminiResp.UsageMetadata) + + return buildResponsesObjectMap( + responsesIDFromGemini(geminiResp, idBase), createdAt, "completed", modelName, + output, buildResponsesUsageMap(usage), &completedAt, info, + ), nil +} + +// buildResponsesOutputFromGemini 构建 Responses 非流式响应的 output 数组。 +// 文本合并为单个 message 项(居首),functionCall 各成一个 function_call 项。 +func buildResponsesOutputFromGemini(geminiResp *dto.GeminiChatResponse, idBase string) []map[string]any { + var textParts []string + var thinkingParts []string + var citations *shared.GroundingCitations + output := make([]map[string]any, 0) + toolIdx := 0 + + for _, candidate := range geminiResp.Candidates { + // 服务端搜索证据:上游真的去搜了,来源必须还原成 Responses 的 + // web_search_call 动作项 + output_text.annotations 引用 + if c := shared.ParseGeminiGrounding(candidate.GroundingMetadata); !c.IsEmpty() { + citations = c + } + if candidate.Content == nil { + continue + } + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" && !isThought { + textParts = append(textParts, part.Text) + } + // 思考 part → Responses 的 reasoning 输出项(与 claude→responses 口径一致) + if part.Text != "" && isThought { + thinkingParts = append(thinkingParts, part.Text) + } + + if part.FunctionCall != nil { + id := geminiResponsesCallID(part.FunctionCall, idBase, toolIdx) + toolIdx++ + output = append(output, map[string]any{ + "type": "function_call", + "id": id, + "call_id": id, + "name": part.FunctionCall.FunctionName, + "arguments": marshalGeminiArgs(part.FunctionCall), + "status": "completed", + }) + } + + if fallback := geminiPartFallbackText(part); fallback != "" { + textParts = append(textParts, fallback) + } + } + } + + if len(textParts) > 0 { + answer := strings.Join(textParts, "") + textContent := map[string]any{ + "type": "output_text", + "text": answer, + "annotations": []any{}, + } + // 引用挂在正文的 annotations 上(url_citation 形态) + if !citations.IsEmpty() { + citations.AlignSupports(answer) + anns := make([]any, 0) + for _, a := range citations.ToResponsesAnnotations() { + anns = append(anns, a) + } + textContent["annotations"] = anns + } + msgItem := map[string]any{ + "type": "message", + "id": fmt.Sprintf("msg_%s", idBase), + "status": "completed", + "role": "assistant", + "content": []map[string]any{textContent}, + } + output = append([]map[string]any{msgItem}, output...) + } + // web_search_call 动作项排在最前:它记录「模型发起过搜索」这一事实, + // 与 annotations(引用了哪些来源)互补 + if item := citations.ToResponsesWebSearchCallItem(idBase); item != nil { + output = append([]map[string]any{item}, output...) + } + // reasoning 项排在最前(与 Responses API 的真实输出顺序一致:思考先于回答) + if len(thinkingParts) > 0 { + reasoningItem := map[string]any{ + "type": "reasoning", + "id": fmt.Sprintf("rs_%s", idBase), + "summary": []map[string]any{{ + "type": "summary_text", + "text": strings.Join(thinkingParts, ""), + }}, + } + output = append([]map[string]any{reasoningItem}, output...) + } + + return output +} + +// responsesIDFromGemini 生成 Responses 响应 ID,优先用上游 responseId; +// idBase 为本次转换的唯一基准(宿主 RequestID 未随 Meta 下沉,以纳秒时间戳替代) +func responsesIDFromGemini(geminiResp *dto.GeminiChatResponse, idBase string) string { + if geminiResp.ResponseID != "" { + return fmt.Sprintf("resp_%s", geminiResp.ResponseID) + } + return fmt.Sprintf("resp_%s", idBase) +} + +// geminiResponsesCallID 取工具调用 ID:Gemini 的 functionCall.id 可选,缺失时合成。 +// Responses 的 function_call 必须带 call_id,客户端下一轮按此 ID 回传结果。 +func geminiResponsesCallID(fc *dto.GeminiFunctionCall, idBase string, idx int) string { + if fc != nil && fc.ID != "" { + return fc.ID + } + return fmt.Sprintf("call_%s_%d", idBase, idx) +} + +// geminiPartFallbackText 将 Responses 的 output_text 无对应块类型的 Gemini part 渲染为文本。 +// 渲染口径与 Gemini→OpenAI 出站一致。 +func geminiPartFallbackText(part *dto.GeminiPart) string { + switch { + case part.InlineData != nil: + return fmt.Sprintf("![image](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data) + case part.ExecutableCode != nil: + return fmt.Sprintf("```%s\n%s\n```", part.ExecutableCode.Language, part.ExecutableCode.Code) + case part.CodeExecutionResult != nil: + return fmt.Sprintf("Execution %s:\n%s", part.CodeExecutionResult.Outcome, part.CodeExecutionResult.Output) + case part.FileData != nil: + return fmt.Sprintf("[file](%s)", part.FileData.FileURI) + } + return "" +} diff --git a/relaykit/relayconvert/internal/native_responses/gemini_to_responses_stream.go b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_stream.go new file mode 100644 index 0000000..21a9e1a --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_stream.go @@ -0,0 +1,414 @@ +package native_responses + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// geminiResponsesToolCall Responses 桥接中聚合的工具调用(Gemini functionCall) +type geminiResponsesToolCall struct { + id string + name string + args string +} + +// GeminiToResponsesStreamConverter 将 Gemini SSE 流转换为 OpenAI Responses 事件流。 +// 事件序列与 chat→responses、claude→responses 两条既有桥接保持一致 +// (response.created → output_item/content_part 增量 → 各项 done → response.completed), +// 每个事件以 StreamEvent{Event: 事件名} 输出,由宿主桥接层写为事件帧。 +type GeminiToResponsesStreamConverter struct{} + +func (c *GeminiToResponsesStreamConverter) ID() string { + return relayconvert.ResponseConverterGeminiChatToOAIResponsesStream +} + +func (c *GeminiToResponsesStreamConverter) From() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *GeminiToResponsesStreamConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *GeminiToResponsesStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Gemini SSE 流转换为 Responses 事件帧序列。 +// reader 提供上游 Gemini 的原生 SSE(Code Assist 包装的解包在宿主侧完成)。 +func (c *GeminiToResponsesStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + // 响应/消息 ID 基准:宿主 RequestID 未随 Meta 下沉,以纳秒时间戳保证单响应内唯一 + idBase := fmt.Sprintf("%d", time.Now().UnixNano()) + respID := fmt.Sprintf("resp_%s", idBase) + msgID := fmt.Sprintf("msg_%s", idBase) + createdAt := int(time.Now().Unix()) + modelName := originModelName(info) + + var ( + totalUsage dto.GeminiUsageMetadata + textBuf strings.Builder + citations *shared.GroundingCitations + sentCreated bool + sentTextDone bool + sentCompleted bool + outputIndex int + contentIndex int + toolIdx int + toolCalls []*geminiResponsesToolCall + toolIndexByID = make(map[string]int) + ) + + // emitEvent 发出一个 Responses 格式事件帧 + emitEvent := func(eventType string, data map[string]any, usage *dto.UsageWithDetails) error { + return chunkWriter(&relayconvert.StreamEvent{Event: eventType, Data: data, Usage: usage}) + } + + // ensureCreated 发出 response.created 与首个 message 项的开场事件 + ensureCreated := func() error { + if sentCreated { + return nil + } + if err := emitEvent("response.created", map[string]any{ + "type": "response.created", + "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, info), + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "in_progress", + "role": "assistant", + "content": []any{}, + }, + }, nil); err != nil { + return err + } + if err := emitEvent("response.content_part.added", map[string]any{ + "type": "response.content_part.added", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": "", + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + sentCreated = true + return nil + } + + // closeTextPart 关闭文本 content part(进入工具调用或流结束时调用) + closeTextPart := func() error { + if sentTextDone { + return nil + } + finishedText := textBuf.String() + if err := emitEvent("response.output_text.done", map[string]any{ + "type": "response.output_text.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "text": finishedText, + }, nil); err != nil { + return err + } + if err := emitEvent("response.content_part.done", map[string]any{ + "type": "response.content_part.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }}, + }, + }, nil); err != nil { + return err + } + sentTextDone = true + outputIndex++ + return nil + } + + // finish 发送各工具调用的收尾事件与 response.completed(随帧携带计费用量) + finish := func() error { + if sentCompleted { + return nil + } + if err := ensureCreated(); err != nil { + return err + } + if err := closeTextPart(); err != nil { + return err + } + + finalOutput := make([]map[string]any, 0) + // 服务端搜索证据:web_search_call 动作项排在最前,引用挂在正文 annotations 上。 + // 只能在收尾还原:groundingMetadata 随末帧到达,正文增量早已推给客户端。 + annotations := make([]any, 0) + if !citations.IsEmpty() { + citations.AlignSupports(textBuf.String()) + for _, a := range citations.ToResponsesAnnotations() { + annotations = append(annotations, a) + } + if item := citations.ToResponsesWebSearchCallItem(respID); item != nil { + finalOutput = append(finalOutput, item) + } + } + if textBuf.Len() > 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{{ + "type": "output_text", + "text": textBuf.String(), + "annotations": annotations, + }}, + }) + } + + for _, tc := range toolCalls { + if err := emitEvent("response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", + "item_id": tc.id, + "output_index": toolIndexByID[tc.id], + "arguments": tc.args, + }, nil); err != nil { + return err + } + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": toolIndexByID[tc.id], + "item": map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "arguments": tc.args, + "status": "completed", + }, + }, nil); err != nil { + return err + } + finalOutput = append(finalOutput, map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "arguments": tc.args, + "status": "completed", + }) + } + + // 本方向客户端可见 usage 与计费口径一致(OpenAI 语义),completed 帧同时携带计费用量; + // 输出缺失时的估算兜底(EstimateStreamOutputTokens)由宿主桥接层处理 + usage := geminiUsageToDetails(&totalUsage) + completedAt := int(time.Now().Unix()) + if err := emitEvent("response.completed", map[string]any{ + "type": "response.completed", + "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, + finalOutput, buildResponsesUsageMap(usage), &completedAt, info), + }, usage); err != nil { + return err + } + sentCompleted = true + return nil + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + // 流中断:计费兜底(Gemini 每 chunk 携带累计 usage + 请求侧估算)由宿主桥接层处理 + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" { + continue + } + if data == "[DONE]" { + break + } + + var geminiResp dto.GeminiChatResponse + if err := json.Unmarshal([]byte(data), &geminiResp); err != nil { + continue + } + + if geminiResp.UsageMetadata != nil { + totalUsage = *geminiResp.UsageMetadata + } + if geminiResp.ModelName != "" && !isModelMapped(info) { + modelName = geminiResp.ModelName + } + + if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { + // 事件流已开场,必须补齐 completed 事件,否则客户端挂起 + if err := finish(); err != nil { + return err + } + return fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) + } + + if err := ensureCreated(); err != nil { + return err + } + + for _, candidate := range geminiResp.Candidates { + // grounding 随末帧(或靠后的帧)到达,先捕获、收尾时统一还原 + if c := shared.ParseGeminiGrounding(candidate.GroundingMetadata); !c.IsEmpty() { + citations = c + } + if candidate.Content == nil { + continue + } + for i := range candidate.Content.Parts { + part := &candidate.Content.Parts[i] + isThought := part.Thought != nil && *part.Thought + + if part.Text != "" { + if isThought { + // 思考内容以 reasoning summary 事件透出(与 claude→responses 口径一致) + if err := emitEvent("response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", + "item_id": msgID, + "output_index": 0, + "summary_index": 0, + "delta": part.Text, + }, nil); err != nil { + return err + } + } else { + textBuf.WriteString(part.Text) + if err := emitEvent("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", + "item_id": msgID, + "output_index": 0, + "content_index": contentIndex, + "delta": part.Text, + }, nil); err != nil { + return err + } + } + } + + if part.FunctionCall != nil { + // 先关闭文本 content part,再开 function_call 项 + if err := closeTextPart(); err != nil { + return err + } + + tc := &geminiResponsesToolCall{ + id: geminiResponsesCallID(part.FunctionCall, idBase, toolIdx), + name: part.FunctionCall.FunctionName, + args: marshalGeminiArgs(part.FunctionCall), + } + toolIdx++ + toolCalls = append(toolCalls, tc) + toolIndexByID[tc.id] = outputIndex + + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "function_call", + "id": tc.id, + "call_id": tc.id, + "name": tc.name, + "status": "in_progress", + }, + }, nil); err != nil { + return err + } + // Gemini 一次给全量参数,单条 delta 发完(收尾的 .done 在 finish 中统一发) + if err := emitEvent("response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", + "item_id": tc.id, + "output_index": outputIndex, + "delta": tc.args, + }, nil); err != nil { + return err + } + outputIndex++ + } + + // Responses 的 output_text 无对应块类型,按既有 Gemini 出站口径渲染为文本 + if fallback := geminiPartFallbackText(part); fallback != "" { + textBuf.WriteString(fallback) + if err := emitEvent("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", + "item_id": msgID, + "output_index": 0, + "content_index": contentIndex, + "delta": fallback, + }, nil); err != nil { + return err + } + } + } + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + // 读流出错:已累计的 usage 无法随错误返回(StreamEvent 只随帧携带),由宿主按估算兜底 + return fmt.Errorf("stream scanner error: %w", err) + } + + // 上游断流未给收尾:仍合成 completed,避免客户端挂起 + return finish() +} diff --git a/relaykit/relayconvert/internal/native_responses/gemini_to_responses_test.go b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_test.go new file mode 100644 index 0000000..7be023c --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/gemini_to_responses_test.go @@ -0,0 +1,435 @@ +package native_responses + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// responsesInboundMeta 构造 Responses 入站 + Gemini 上游的 Meta +func responsesInboundMeta(isStream bool) *convmeta.Values { + return &convmeta.Values{ + OriginModelName: "gemini-3-pro", + UpstreamModelName: "gemini-3-pro", + ChannelMetaAttached: true, + IsStream: isStream, + } +} + +// runGeminiToResponsesStream 跑流式转换并收集 StreamEvent 序列 +func runGeminiToResponsesStream(t *testing.T, meta convmeta.Meta, upstream string) ([]*relayconvert.StreamEvent, error) { + t.Helper() + conv := &GeminiToResponsesStreamConverter{} + var events []*relayconvert.StreamEvent + err := conv.ConvertStreamResponse(context.Background(), meta, strings.NewReader(upstream), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("chunk 类型应为 *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +// responsesEventTypes 提取事件名序列(校验帧形态:Responses 客户端为事件帧) +func responsesEventTypes(t *testing.T, events []*relayconvert.StreamEvent) []string { + t.Helper() + names := make([]string, 0, len(events)) + for _, ev := range events { + if ev.Event == "" { + t.Fatalf("Responses 客户端应为事件帧(Event 非空): %+v", ev) + } + names = append(names, ev.Event) + } + return names +} + +// responsesDataOf 取事件负载并断言为 map +func responsesDataOf(t *testing.T, ev *relayconvert.StreamEvent) map[string]any { + t.Helper() + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("data 类型应为 map[string]any, got %T", ev.Data) + } + return data +} + +// ===== 非流式 ===== + +// TestBuildResponsesOutputFromGemini_TextAndTool 思考 part 转为居首的 reasoning 项, +// 文本合并为 message 项,functionCall 各成一个 function_call 项。 +// +// 思考内容此前在非流式响应中被跳过(能力失恒:推理模型的思考过程静默消失), +// 现转为 Responses 的 reasoning 输出项——与 claude→responses 方向同口径。 +func TestBuildResponsesOutputFromGemini_TextAndTool(t *testing.T) { + thought := true + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + Parts: []dto.GeminiPart{ + {Text: "思考中", Thought: &thought}, + {Text: "你好"}, + {Text: "世界"}, + {FunctionCall: &dto.GeminiFunctionCall{ + FunctionName: "get_weather", + Arguments: map[string]any{"city": "北京"}, + }}, + }, + }, + FinishReason: "STOP", + }}, + } + + output := buildResponsesOutputFromGemini(geminiResp, "req123") + + if len(output) != 3 { + t.Fatalf("output 项数 = %d, want 3(reasoning + message + function_call): %+v", len(output), output) + } + + reasoning := output[0] + if reasoning["type"] != "reasoning" { + t.Fatalf("首项应为 reasoning: %+v", reasoning) + } + summary := reasoning["summary"].([]map[string]any) + if len(summary) != 1 || summary[0]["type"] != "summary_text" || summary[0]["text"] != "思考中" { + t.Errorf("reasoning.summary 形态不符: %+v", summary) + } + + msg := output[1] + if msg["type"] != "message" || msg["status"] != "completed" || msg["role"] != "assistant" { + t.Errorf("次项应为 completed 的 assistant message: %+v", msg) + } + content := msg["content"].([]map[string]any) + if len(content) != 1 || content[0]["type"] != "output_text" { + t.Fatalf("message content 结构不对: %+v", content) + } + // 思考内容不得混进正文(它有自己的 reasoning 项) + if got := content[0]["text"]; got != "你好世界" { + t.Errorf("正文 = %q, want %q(思考内容应独立成 reasoning 项)", got, "你好世界") + } + + fc := output[2] + if fc["type"] != "function_call" || fc["name"] != "get_weather" { + t.Errorf("末项应为 function_call: %+v", fc) + } + if fc["id"] != fc["call_id"] { + t.Errorf("id 与 call_id 应一致: %+v", fc) + } + var args map[string]any + if err := json.Unmarshal([]byte(fc["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments 应为 JSON 字符串: %v", err) + } + if args["city"] != "北京" { + t.Errorf("参数丢失: %+v", args) + } +} + +// TestGeminiResponsesCallID_PrefersUpstreamID Gemini 的 functionCall.id 可选, +// 有则直接用,无则合成(Responses 的 function_call 必须带 call_id)。 +func TestGeminiResponsesCallID_PrefersUpstreamID(t *testing.T) { + withID := geminiResponsesCallID(&dto.GeminiFunctionCall{ID: "up_1", FunctionName: "f"}, "base", 0) + if withID != "up_1" { + t.Errorf("有上游 id 时应直接使用, got %q", withID) + } + + a := geminiResponsesCallID(&dto.GeminiFunctionCall{FunctionName: "f"}, "base", 0) + b := geminiResponsesCallID(&dto.GeminiFunctionCall{FunctionName: "f"}, "base", 1) + if a == "" || a == b { + t.Errorf("合成 ID 必须非空且互不相同: %q / %q", a, b) + } + if !strings.HasPrefix(a, "call_") { + t.Errorf("合成 ID 应为 call_ 前缀: %q", a) + } +} + +// TestGeminiToResponsesResponse_Body 正常路径:转换结果为 Responses 对象, +// 用量按 OpenAI 语义(input 含缓存、output 含思考、reasoning_tokens 单列)。 +func TestGeminiToResponsesResponse_Body(t *testing.T) { + geminiResp := &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{Role: "model", Parts: []dto.GeminiPart{{Text: "你好"}}}, + FinishReason: "STOP", + }}, + ModelName: "gemini-3-pro", + ResponseID: "gr_1", + UsageMetadata: &dto.GeminiUsageMetadata{ + PromptTokenCount: 50, + CachedContentTokenCount: 10, + CandidatesTokenCount: 8, + ThoughtsTokenCount: 3, + TotalTokenCount: 61, + }, + } + + conv := &GeminiToResponsesResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), responsesInboundMeta(false), geminiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got, ok := res.(map[string]any) + if !ok { + t.Fatalf("返回类型应为 map[string]any, got %T", res) + } + + if got["object"] != "response" || got["status"] != "completed" { + t.Errorf("Responses 骨架不对: object=%v status=%v", got["object"], got["status"]) + } + if id, _ := got["id"].(string); id != "resp_gr_1" { + t.Errorf("id = %q, want resp_gr_1(应优先用上游 responseId)", id) + } + if got["completed_at"] == nil { + t.Error("completed 状态必须带 completed_at") + } + if got["model"] != "gemini-3-pro" { + t.Errorf("model = %v, want gemini-3-pro", got["model"]) + } + + u := got["usage"].(map[string]any) + if u["input_tokens"].(int) != 50 { + t.Errorf("input_tokens = %v, want 50(OpenAI 语义含缓存)", u["input_tokens"]) + } + if u["output_tokens"].(int) != 11 { + t.Errorf("output_tokens = %v, want 11(candidates+thoughts)", u["output_tokens"]) + } + if rt := u["output_tokens_details"].(map[string]any)["reasoning_tokens"].(int); rt != 3 { + t.Errorf("reasoning_tokens = %v, want 3", rt) + } + if ct := u["input_tokens_details"].(map[string]any)["cached_tokens"].(int); ct != 10 { + t.Errorf("cached_tokens = %v, want 10", ct) + } +} + +// TestGeminiToResponsesResponse_SafetyBlock 安全过滤命中时返回错误而非合成响应 +func TestGeminiToResponsesResponse_SafetyBlock(t *testing.T) { + conv := &GeminiToResponsesResponseConverter{} + _, err := conv.ConvertResponse(context.Background(), responsesInboundMeta(false), &dto.GeminiChatResponse{ + PromptFeedback: &dto.GeminiPromptFeedback{BlockReason: "SAFETY"}, + }) + if err == nil { + t.Fatal("安全过滤应返回错误") + } + if !strings.Contains(err.Error(), "SAFETY") { + t.Errorf("错误信息应包含 blockReason: %v", err) + } +} + +// ===== 流式 ===== + +// TestGeminiToResponsesStream_EventSequence 完整事件序列: +// created → output_item.added → content_part.added → 文本/思考增量 → 三个 done → completed +func TestGeminiToResponsesStream_EventSequence(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"想一下","thought":true}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"你好"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"世界"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"cachedContentTokenCount":10,"candidatesTokenCount":8,"thoughtsTokenCount":3,"totalTokenCount":61}}`, + ``, + ``, + }, "\n") + + events, err := runGeminiToResponsesStream(t, responsesInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := responsesEventTypes(t, events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.reasoning_summary_text.delta", + "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // 思考增量走 reasoning summary,不得混进正文 + if responsesDataOf(t, events[3])["delta"] != "想一下" { + t.Errorf("reasoning delta 不对: %+v", responsesDataOf(t, events[3])) + } + if responsesDataOf(t, events[6])["text"] != "你好世界" { + t.Errorf("output_text.done 文本 = %v, want 你好世界", responsesDataOf(t, events[6])["text"]) + } + + completed := responsesDataOf(t, events[9])["response"].(map[string]any) + if completed["status"] != "completed" { + t.Errorf("最终状态 = %v", completed["status"]) + } + out := completed["output"].([]map[string]any) + if len(out) != 1 || out[0]["type"] != "message" { + t.Fatalf("completed.output 应含单个 message 项: %+v", out) + } + u := completed["usage"].(map[string]any) + if u["input_tokens"].(int) != 50 || u["output_tokens"].(int) != 11 { + t.Errorf("completed usage 不对: %+v", u) + } + + // 计费用量随 completed 帧携带(本方向与客户端可见口径一致) + if events[9].Usage == nil { + t.Fatal("completed 帧必须携带计费用量") + } + if events[9].Usage.PromptTokens != 50 || events[9].Usage.CompletionTokens != 11 { + t.Errorf("计费用量 = %d/%d, want 50/11", events[9].Usage.PromptTokens, events[9].Usage.CompletionTokens) + } + if events[9].Usage.PromptTokensDetails == nil || events[9].Usage.PromptTokensDetails.CachedTokens != 10 { + t.Errorf("计费缓存明细不对: %+v", events[9].Usage.PromptTokensDetails) + } +} + +// TestGeminiToResponsesStream_ToolCall 工具调用前必须先关闭文本 content part, +// 且 function_call 项的 output_index 与文本项不冲突。 +func TestGeminiToResponsesStream_ToolCall(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"查一下"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"北京"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}`, + ``, + ``, + }, "\n") + + events, err := runGeminiToResponsesStream(t, responsesInboundMeta(true), upstream) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := responsesEventTypes(t, events) + want := []string{ + "response.created", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + // 工具调用前先收掉文本 part + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.output_item.added", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + "response.output_item.done", + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + + // 文本项 output_index=0,工具项 output_index=1 + if idx := responsesDataOf(t, events[6])["output_index"].(int); idx != 0 { + t.Errorf("文本项 output_index = %v, want 0", idx) + } + if idx := responsesDataOf(t, events[7])["output_index"].(int); idx != 1 { + t.Errorf("工具项 output_index = %v, want 1", idx) + } + + toolItem := responsesDataOf(t, events[7])["item"].(map[string]any) + if toolItem["type"] != "function_call" || toolItem["name"] != "get_weather" { + t.Errorf("function_call 项不对: %+v", toolItem) + } + + completed := responsesDataOf(t, events[11])["response"].(map[string]any) + out := completed["output"].([]map[string]any) + if len(out) != 2 { + t.Fatalf("completed.output 应含 message + function_call: %+v", out) + } + if out[0]["type"] != "message" || out[1]["type"] != "function_call" { + t.Errorf("output 顺序不对: %+v", out) + } +} + +// TestGeminiToResponsesStream_EmptyUpstreamStillCompletes 上游无有效 chunk 时仍须合成 +// created/completed,否则 Responses 客户端一直等 response.completed。 +func TestGeminiToResponsesStream_EmptyUpstreamStillCompletes(t *testing.T) { + events, err := runGeminiToResponsesStream(t, responsesInboundMeta(true), "\n\n") + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + got := responsesEventTypes(t, events) + if len(got) == 0 || got[0] != "response.created" || got[len(got)-1] != "response.completed" { + t.Fatalf("必须以 created 开场、completed 收尾, got %v", got) + } +} + +// TestGeminiToResponsesStream_SafetyBlockCompletes 安全过滤命中时事件流已开场, +// 必须补齐 completed 再返回错误。 +func TestGeminiToResponsesStream_SafetyBlockCompletes(t *testing.T) { + events, err := runGeminiToResponsesStream(t, responsesInboundMeta(true), + "data: {\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n") + if err == nil { + t.Fatal("安全过滤应返回错误") + } + + got := responsesEventTypes(t, events) + if len(got) == 0 || got[len(got)-1] != "response.completed" { + t.Errorf("必须以 response.completed 收尾, got %v", got) + } +} + +// stashMeta 测试用 Meta 实现:嵌入 convmeta.Values 并实现 ResponsesStash 能力接口 +// (对应宿主 RelayInfo 的 info.ResponsesRequest 字段)。 +type stashMeta struct { + *convmeta.Values + stashed *dto.OpenAIResponsesRequest +} + +func (m *stashMeta) StashResponsesRequest(req *dto.OpenAIResponsesRequest) { m.stashed = req } +func (m *stashMeta) StashedResponsesRequest() *dto.OpenAIResponsesRequest { return m.stashed } + +// TestResponsesObjectEchoesStashedRequest 合成响应应 echo 宿主 stash 的请求参数 +// (temperature / top_p / max_output_tokens / instructions),未 stash 时回退默认值。 +func TestResponsesObjectEchoesStashedRequest(t *testing.T) { + temp := 0.3 + topP := 0.9 + maxOut := uint(256) + meta := &stashMeta{Values: responsesInboundMeta(false)} + meta.StashResponsesRequest(&dto.OpenAIResponsesRequest{ + Temperature: &temp, + TopP: &topP, + MaxOutputTokens: &maxOut, + Instructions: json.RawMessage(`"be brief"`), + }) + + conv := &GeminiToResponsesResponseConverter{} + res, err := conv.ConvertResponse(context.Background(), meta, &dto.GeminiChatResponse{ + Candidates: []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{Role: "model", Parts: []dto.GeminiPart{{Text: "hi"}}}, + FinishReason: "STOP", + }}, + }) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got := res.(map[string]any) + if got["temperature"] != 0.3 || got["top_p"] != 0.9 { + t.Errorf("temperature/top_p 未 echo: %v / %v", got["temperature"], got["top_p"]) + } + if mo, ok := got["max_output_tokens"].(*int); !ok || *mo != 256 { + t.Errorf("max_output_tokens 未 echo: %v", got["max_output_tokens"]) + } + if string(got["instructions"].(json.RawMessage)) != `"be brief"` { + t.Errorf("instructions 未 echo: %v", got["instructions"]) + } + + // 未 stash:回退默认值 + res2, err := conv.ConvertResponse(context.Background(), responsesInboundMeta(false), &dto.GeminiChatResponse{}) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + got2 := res2.(map[string]any) + if got2["temperature"] != 1.0 || got2["top_p"] != 1.0 { + t.Errorf("默认 temperature/top_p 不对: %v / %v", got2["temperature"], got2["top_p"]) + } +} diff --git a/relaykit/relayconvert/internal/native_responses/helper.go b/relaykit/relayconvert/internal/native_responses/helper.go new file mode 100644 index 0000000..ff4cf2c --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/helper.go @@ -0,0 +1,278 @@ +// Package native_responses 承载「Claude / Gemini 原生上游 → OpenAI Responses 客户端」的响应桥。 +// +// 请求侧由两段式请求转换完成(Responses→OpenAI→Claude/Gemini),本包只做响应侧: +// 把原生上游的响应/SSE 转成 Responses 格式。代码从宿主 +// relay/channel/claude/responses_bridge.go 与 relay/channel/gemini/responses_bridge.go 逐行平移; +// 宿主 openai 包的 EmitResponsesSSE / BuildResponsesObjectMap / BuildResponsesUsageMap +// 的纯逻辑部分在本包内做私有等价实现,事件发射改为 relayconvert.StreamEvent。 +package native_responses + +import ( + "encoding/json" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// ========== Responses 对象装配(宿主 openai.BuildResponsesObjectMap / BuildResponsesUsageMap 的等价实现) ========== + +// responsesRequestEcho 合成 Responses 响应时需回显(echo)的请求参数 +type responsesRequestEcho struct { + temperature *float64 + topP *float64 + maxOutputTokens *int + instructions any +} + +// extractResponsesRequestEcho 从 ResponsesStash 能力接口(宿主 stash 的请求快照)提取 +// 合成响应应 echo 的请求参数。info 未实现该接口或快照缺失(直连路径/异常)时 +// 回退 OpenAI 默认值(temperature=1.0 / top_p=1.0,其余 nil)。 +func extractResponsesRequestEcho(info convmeta.Meta) responsesRequestEcho { + echo := responsesRequestEcho{temperature: float64Ptr(1.0), topP: float64Ptr(1.0)} + stash, ok := info.(convmeta.ResponsesStash) + if !ok { + return echo + } + rr := stash.StashedResponsesRequest() + if rr == nil { + return echo + } + if rr.Temperature != nil { + echo.temperature = rr.Temperature + } + if rr.TopP != nil { + echo.topP = rr.TopP + } + if rr.MaxOutputTokens != nil { + m := int(*rr.MaxOutputTokens) + echo.maxOutputTokens = &m + } + if len(rr.Instructions) > 0 { + echo.instructions = json.RawMessage(rr.Instructions) + } + return echo +} + +// buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map。 +// 请求参数从 ResponsesStash 快照 echo(快照缺失时回退默认值); +// store 恒为 false——合成响应不落上游存储,客户端不可经生命周期端点 retrieve。 +func buildResponsesObjectMap(respID string, createdAt int, status string, model string, output any, usageObj map[string]any, completedAt *int, info convmeta.Meta) map[string]any { + echo := extractResponsesRequestEcho(info) + m := map[string]any{ + "id": respID, + "object": "response", + "created_at": createdAt, + "status": status, + "error": nil, + "incomplete_details": nil, + "instructions": echo.instructions, + "max_output_tokens": echo.maxOutputTokens, + "model": model, + "output": output, + "parallel_tool_calls": true, + "previous_response_id": nil, + "reasoning": map[string]any{"effort": nil, "summary": nil}, + "store": false, + "temperature": *echo.temperature, + "text": map[string]any{"format": map[string]any{"type": "text"}}, + "tool_choice": "auto", + "tools": []any{}, + "top_p": *echo.topP, + "truncation": "disabled", + "user": nil, + "metadata": map[string]any{}, + } + if completedAt != nil { + m["completed_at"] = *completedAt + } + if usageObj != nil { + m["usage"] = usageObj + } + return m +} + +// buildResponsesUsageMap 构建 Responses API usage 对象 +func buildResponsesUsageMap(usage *dto.UsageWithDetails) map[string]any { + inputDetails := map[string]any{"cached_tokens": 0} + outputDetails := map[string]any{"reasoning_tokens": 0} + if usage.PromptTokensDetails != nil { + inputDetails = map[string]any{ + "cached_tokens": usage.PromptTokensDetails.CachedTokens, + "cache_write_tokens": usage.PromptTokensDetails.CacheWriteTokens, + "audio_tokens": usage.PromptTokensDetails.AudioTokens, + } + } + if usage.CompletionTokenDetails != nil { + outputDetails = map[string]any{ + "reasoning_tokens": usage.CompletionTokenDetails.ReasoningTokens, + "audio_tokens": usage.CompletionTokenDetails.AudioTokens, + "accepted_prediction_tokens": usage.CompletionTokenDetails.AcceptedPredictionTokens, + "rejected_prediction_tokens": usage.CompletionTokenDetails.RejectedPredictionTokens, + } + } + return map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + "input_tokens_details": inputDetails, + "output_tokens_details": outputDetails, + } +} + +// ========== 用量换算 ========== + +// claudeUsageToTokenDetails 将 ClaudeUsage 转换为 TokenDetails(含 cache token 细分), +// 与宿主同名函数等价 +func claudeUsageToTokenDetails(u *dto.ClaudeUsage) *dto.TokenDetails { + if u == nil { + return nil + } + td := &dto.TokenDetails{ + CachedTokens: u.CacheReadInputTokens, + CachedCreationTokens: u.CacheCreationInputTokens, + } + if u.CacheCreation != nil { + td.CachedCreation5mTokens = u.CacheCreation.Ephemeral5mInputTokens + td.CachedCreation1hTokens = u.CacheCreation.Ephemeral1hInputTokens + } + return td +} + +// claudeVisibleUsage 按 OpenAI 语义换算 Claude 用量(Responses 客户端可见值: +// input 含缓存,cached 为子集) +func claudeVisibleUsage(u *dto.ClaudeUsage) *dto.UsageWithDetails { + if u == nil { + return &dto.UsageWithDetails{} + } + promptTotal := u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens + return &dto.UsageWithDetails{ + PromptTokens: promptTotal, + CompletionTokens: u.OutputTokens, + TotalTokens: promptTotal + u.OutputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(u), + } +} + +// claudeBillingUsage 按 Claude 计费口径换算(与宿主 buildUsageFromClaude 等价: +// input 不含缓存,cache 读/写明细随 PromptTokensDetails 透传) +func claudeBillingUsage(u *dto.ClaudeUsage) *dto.UsageWithDetails { + if u == nil { + return &dto.UsageWithDetails{} + } + return &dto.UsageWithDetails{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.InputTokens + u.OutputTokens, + PromptTokensDetails: claudeUsageToTokenDetails(u), + } +} + +// geminiUsageToDetails 按 Gemini 计费口径换算(与宿主 geminiUsageToCommon 等价: +// prompt 含 cached、completion 含 thoughts、模态明细透传)。 +// 本方向 Responses 的客户端可见 usage 与计费口径**恰好一致**(都是 OpenAI 语义), +// 故可见值与 StreamEvent.Usage 共用本函数。宿主 common.Usage 的 CacheIncludedInPrompt +// 标志在 UsageWithDetails 上无对应字段,由宿主桥接层按「Gemini 上游」方向补挂。 +func geminiUsageToDetails(um *dto.GeminiUsageMetadata) *dto.UsageWithDetails { + if um == nil { + return &dto.UsageWithDetails{} + } + usage := &dto.UsageWithDetails{ + PromptTokens: um.PromptTokenCount, + // Gemini 的 candidatesTokenCount 不含思考 token,OpenAI 口径的 completion 含 + // reasoning(子集语义),必须 candidates+thoughts 合计,否则思考 token 漏计 + CompletionTokens: um.CandidatesTokenCount + um.ThoughtsTokenCount, + TotalTokens: um.TotalTokenCount, + PromptTokensDetails: &dto.TokenDetails{ + CachedTokens: um.CachedContentTokenCount, + }, + CompletionTokenDetails: &dto.TokenDetails{ + ReasoningTokens: um.ThoughtsTokenCount, + }, + } + + // 转换模态 Token 明细 + for _, mtc := range um.PromptTokensDetails { + geminiModalityToTokenDetails(mtc, usage.PromptTokensDetails) + } + for _, mtc := range um.CandidatesTokensDetails { + geminiModalityToTokenDetails(mtc, usage.CompletionTokenDetails) + } + + return usage +} + +// geminiModalityToTokenDetails 将 Gemini 模态 Token 计数转换为 TokenDetails 字段 +func geminiModalityToTokenDetails(mtc dto.GeminiModalityTokenCount, td *dto.TokenDetails) { + if td == nil { + return + } + switch mtc.Modality { + case "TEXT": + td.TextTokens += mtc.TokenCount + case "IMAGE": + td.ImageTokens += mtc.TokenCount + case "AUDIO": + td.AudioTokens += mtc.TokenCount + } +} + +// ========== Gemini part 辅助(与 claude_gemini 包各自私有,避免跨包依赖) ========== + +// geminiFunctionArgs 提取 functionCall 参数,nil 时回退为空对象 +func geminiFunctionArgs(fc *dto.GeminiFunctionCall) any { + if fc == nil || fc.Arguments == nil { + return map[string]any{} + } + return fc.Arguments +} + +// marshalGeminiArgs 将 functionCall 参数序列化为 Responses 期望的 JSON 字符串 +func marshalGeminiArgs(fc *dto.GeminiFunctionCall) string { + b, err := json.Marshal(geminiFunctionArgs(fc)) + if err != nil { + return "{}" + } + return string(b) +} + +// ========== Meta 相关辅助 ========== + +// originModelName 取入站模型名(nil Meta 安全) +func originModelName(info convmeta.Meta) string { + if info == nil { + return "" + } + return info.GetOriginModelName() +} + +// isModelMapped 判断是否经过模型名映射(宿主 info.ChannelMeta.IsModelMapped 的等价判定: +// 附加了渠道信息且上游模型名与入站模型名不同) +func isModelMapped(info convmeta.Meta) bool { + if info == nil || !info.HasChannelMeta() { + return false + } + upstream := info.GetUpstreamModelName() + return upstream != "" && upstream != info.GetOriginModelName() +} + +// float64Ptr 返回 float64 的指针 +func float64Ptr(v float64) *float64 { return &v } + +// claudeWebSearchCallItem 把 claude 的 web_search server_tool_use 构造为 Responses 的 +// web_search_call 输出项。argsJSON 为服务端工具的 input 序列化({"query":"..."}), +// 解析失败时 query 留空(codex 等客户端按项类型展示搜索动作,query 缺失可容忍)。 +func claudeWebSearchCallItem(id, argsJSON string) map[string]any { + query := "" + var input map[string]any + if err := json.Unmarshal([]byte(argsJSON), &input); err == nil { + if q, ok := input["query"].(string); ok { + query = q + } + } + return map[string]any{ + "type": "web_search_call", + "id": id, + "status": "completed", + "action": map[string]any{"type": "search", "query": query}, + } +} diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go new file mode 100644 index 0000000..c6f44c8 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go @@ -0,0 +1,349 @@ +package oai_chat + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// ClaudeToOpenAIRequestConverter 将 Claude Messages API 请求转换为 OpenAI Chat Completions 请求。 +type ClaudeToOpenAIRequestConverter struct{} + +func (c *ClaudeToOpenAIRequestConverter) ID() string { + return relayconvert.ConverterClaudeMessagesToOpenAIChat +} + +func (c *ClaudeToOpenAIRequestConverter) From() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *ClaudeToOpenAIRequestConverter) To() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *ClaudeToOpenAIRequestConverter) Quality() relayconvert.RequestConverterQuality { + return relayconvert.RequestConverterQualityFair +} + +func (c *ClaudeToOpenAIRequestConverter) ConvertRequest( + ctx context.Context, + info convmeta.Meta, + request any, +) (any, error) { + claudeReq, ok := request.(*dto.ClaudeRequest) + if !ok { + return nil, fmt.Errorf("expected *dto.ClaudeRequest, got %T", request) + } + + openaiReq := &dto.GeneralOpenAIRequest{ + Model: info.GetUpstreamModelName(), + Messages: make([]dto.Message, 0), + } + + if claudeReq.MaxTokens != nil { + v := int(*claudeReq.MaxTokens) + openaiReq.MaxTokens = &v + } else { + v := 4096 + openaiReq.MaxTokens = &v + } + + openaiReq.Temperature = claudeReq.Temperature + openaiReq.TopP = claudeReq.TopP + openaiReq.Stream = claudeReq.Stream + + if claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled" { + openaiReq.ReasoningEffort = c2oConvertThinkingToReasoningEffort(claudeReq.Thinking) + } + + if len(claudeReq.StopSequences) > 0 { + openaiReq.Stop = claudeReq.StopSequences + } + + if len(claudeReq.Tools) > 0 { + openaiReq.Tools = make([]dto.Tool, 0, len(claudeReq.Tools)) + for _, t := range claudeReq.Tools { + // 服务端内置工具(web_search_* / code_execution_* 等,type 非空且非 custom) + // 不能伪装成自定义函数——否则上游模型会调用一个客户端从未定义、无人执行的假函数。 + // web_search 在 chat 协议有原生对应物(顶层 web_search_options),在下方单独映射; + // 其余内置工具(code_execution / computer 等)chat 确实无对应物,只能跳过。 + if t.Type != "" && t.Type != "custom" { + continue + } + openaiReq.Tools = append(openaiReq.Tools, dto.Tool{ + Type: "function", + Function: dto.FunctionDef{ + Name: t.Name, + Description: t.Description, + Parameters: t.InputSchema, + }, + }) + } + if len(openaiReq.Tools) == 0 { + openaiReq.Tools = nil + } + } + + // 服务端联网搜索:Claude 的 web_search 内置工具 → chat 的 web_search_options。 + // chat 是跨原生方向的转换中枢(Claude→OpenAI→Gemini/Responses 两跳链), + // 这里不承载就等于整条链上的搜索能力全部丢失。 + if spec := shared.DetectWebSearchFromClaudeTools(claudeReq.Tools); spec != nil { + openaiReq.WebSearchOptions = spec.ToOpenAIOptions() + } + + if claudeReq.ToolChoice != nil { + openaiReq.ToolChoice = c2oConvertToolChoice(claudeReq.ToolChoice) + } + + if claudeReq.System != nil { + systemText := extractClaudeSystemText(claudeReq.System) + if systemText != "" { + openaiReq.Messages = append(openaiReq.Messages, dto.Message{ + Role: "system", + Content: systemText, + }) + } + } + + for _, msg := range claudeReq.Messages { + switch msg.Role { + case "user": + openaiMsgs := convertClaudeUserMessage(msg) + openaiReq.Messages = append(openaiReq.Messages, openaiMsgs...) + case "assistant": + openaiMsg := convertClaudeAssistantMessage(msg) + openaiReq.Messages = append(openaiReq.Messages, openaiMsg) + } + } + + return openaiReq, nil +} + +// extractClaudeSystemText 提取 Claude system 字段中的文本(string 或 text 块数组)。 +func extractClaudeSystemText(system any) string { + switch v := system.(type) { + case string: + return v + case []any: + var parts []string + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + } + return c2oJoinParts(parts) + default: + return "" + } +} + +// convertClaudeUserMessage 转换 Claude user 消息。 +// tool_result 块会拆分为独立的 tool 角色消息,因此一条 Claude 消息可能产出多条 OpenAI 消息。 +func convertClaudeUserMessage(msg dto.ClaudeMessage) []dto.Message { + var results []dto.Message + + switch v := msg.Content.(type) { + case string: + results = append(results, dto.Message{Role: "user", Content: v}) + case []any: + var contentParts []dto.ContentPart + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok { + contentParts = append(contentParts, dto.ContentPart{Type: "text", Text: text}) + } + case "tool_result": + toolUseID, _ := m["tool_use_id"].(string) + toolContent := "" + if c, ok := m["content"].(string); ok { + toolContent = c + } else if cMap, ok := m["content"].(map[string]any); ok { + if b, err := json.Marshal(cMap); err == nil { + toolContent = string(b) + } + } else if cArr, ok := m["content"].([]any); ok { + // 内容块数组(Claude 规范形式,如 [{type:"text",text:"..."}]): + // 拼接文本块,否则工具结果会被替换为空字符串导致内容丢失 + var parts []string + for _, item := range cArr { + if b, ok := item.(map[string]any); ok && b["type"] == "text" { + if text, ok := b["text"].(string); ok { + parts = append(parts, text) + } + } + } + toolContent = c2oJoinParts(parts) + } + if len(contentParts) > 0 { + if len(contentParts) == 1 && contentParts[0].Type == "text" { + results = append(results, dto.Message{Role: "user", Content: contentParts[0].Text}) + } else { + results = append(results, dto.Message{Role: "user", Content: contentParts}) + } + contentParts = nil + } + results = append(results, dto.Message{Role: "tool", Content: toolContent, ToolCallID: toolUseID}) + case "image": + if source, ok := m["source"].(map[string]any); ok { + mediaType, _ := source["media_type"].(string) + data, _ := source["data"].(string) + url, _ := source["url"].(string) + if data != "" && mediaType != "" { + contentParts = append(contentParts, dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{URL: fmt.Sprintf("data:%s;base64,%s", mediaType, data), Detail: "auto"}, + }) + } else if url != "" { + contentParts = append(contentParts, dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{URL: url, Detail: "auto"}, + }) + } + } + } + } + if len(contentParts) > 0 { + if len(contentParts) == 1 && contentParts[0].Type == "text" { + results = append(results, dto.Message{Role: "user", Content: contentParts[0].Text}) + } else { + results = append(results, dto.Message{Role: "user", Content: contentParts}) + } + } + default: + results = append(results, dto.Message{Role: "user", Content: fmt.Sprintf("%v", v)}) + } + + if len(results) == 0 { + results = append(results, dto.Message{Role: "user", Content: ""}) + } + return results +} + +// convertClaudeAssistantMessage 转换 Claude assistant 消息(文本 / thinking / tool_use 块)。 +func convertClaudeAssistantMessage(msg dto.ClaudeMessage) dto.Message { + result := dto.Message{Role: "assistant"} + switch v := msg.Content.(type) { + case string: + result.Content = v + case []any: + var textParts []string + var toolCalls []dto.ToolCall + var reasoningParts []string + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + switch m["type"] { + case "text": + if text, ok := m["text"].(string); ok { + textParts = append(textParts, text) + } + case "thinking": + if thinking, ok := m["thinking"].(string); ok && thinking != "" { + reasoningParts = append(reasoningParts, thinking) + } + case "tool_use": + id, _ := m["id"].(string) + name, _ := m["name"].(string) + argsJSON := "{}" + if input, ok := m["input"]; ok { + if b, err := json.Marshal(input); err == nil { + argsJSON = string(b) + } + } + toolCalls = append(toolCalls, dto.ToolCall{ + ID: id, Type: "function", + Function: dto.FunctionCall{Name: name, Arguments: argsJSON}, + }) + } + } + result.Content = c2oJoinParts(textParts) + if len(reasoningParts) > 0 { + rc := c2oJoinParts(reasoningParts) + result.ReasoningContent = &rc + } + if len(toolCalls) > 0 { + result.ToolCalls = toolCalls + } + default: + result.Content = fmt.Sprintf("%v", v) + } + return result +} + +// c2oJoinParts 用换行符拼接多个文本片段。 +func c2oJoinParts(parts []string) string { + if len(parts) == 0 { + return "" + } + result := make([]byte, 0, 64) + for i, p := range parts { + if i > 0 { + result = append(result, '\n') + } + result = append(result, p...) + } + return string(result) +} + +// c2oConvertThinkingToReasoningEffort 将 Claude thinking budget 映射为 OpenAI reasoning_effort 档位。 +func c2oConvertThinkingToReasoningEffort(thinking *dto.ClaudeThinking) string { + if thinking.BudgetTokens == nil { + return "medium" + } + budget := *thinking.BudgetTokens + switch { + case budget <= 2048: + return "low" + case budget <= 16384: + return "medium" + default: + return "high" + } +} + +// c2oConvertToolChoice 将 Claude tool_choice 映射为 OpenAI tool_choice。 +func c2oConvertToolChoice(toolChoice any) any { + if toolChoice == nil { + return nil + } + switch v := toolChoice.(type) { + case string: + return v + case map[string]any: + tcType, _ := v["type"].(string) + switch tcType { + case "auto": + return "auto" + case "any": + return "required" + case "none": + return "none" + case "tool": + name, _ := v["name"].(string) + if name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + return "required" + } + } + return toolChoice +} + +// 转换器在宿主应用的包初始化阶段注册,而非在此 internal 实现包中完成。 diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go new file mode 100644 index 0000000..d5a361e --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go @@ -0,0 +1,432 @@ +package oai_chat + +import ( + "context" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// newClaudeInboundMeta 构造 Claude 入站方向测试用的 Meta(模型名已映射:origin != upstream)。 +func newClaudeInboundMeta() *convmeta.Values { + return &convmeta.Values{ + OriginModelName: "claude-sonnet-4", + UpstreamModelName: "gpt-4o", + ChannelMetaAttached: true, + } +} + +func TestClaudeToOpenAIRequestConverter_Metadata(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + + if converter.ID() != relayconvert.ConverterClaudeMessagesToOpenAIChat { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ConverterClaudeMessagesToOpenAIChat) + } + if converter.From() != types.RelayFormatClaude { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatClaude) + } + if converter.To() != types.RelayFormatOpenAI { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatOpenAI) + } + if converter.Quality() != relayconvert.RequestConverterQualityFair { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.RequestConverterQualityFair) + } +} + +func TestClaudeToOpenAIRequestConverter_InvalidType(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + if _, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), "not a request"); err == nil { + t.Fatal("expected type assertion error, got nil") + } +} + +func TestClaudeToOpenAIRequestConverter_BasicConversion(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + maxTokens := uint(2000) + temperature := 0.7 + topP := 0.9 + stream := true + + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + MaxTokens: &maxTokens, + Temperature: &temperature, + TopP: &topP, + Stream: &stream, + StopSequences: []string{"STOP"}, + System: "you are helpful", + Messages: []dto.ClaudeMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + }, + } + + result, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + openaiReq, ok := result.(*dto.GeneralOpenAIRequest) + if !ok { + t.Fatalf("expected *dto.GeneralOpenAIRequest, got %T", result) + } + + // 模型名取上游模型名 + if openaiReq.Model != "gpt-4o" { + t.Errorf("Model = %q, want gpt-4o", openaiReq.Model) + } + if openaiReq.MaxTokens == nil || *openaiReq.MaxTokens != 2000 { + t.Errorf("MaxTokens = %v, want 2000", openaiReq.MaxTokens) + } + if openaiReq.Temperature == nil || *openaiReq.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7", openaiReq.Temperature) + } + if openaiReq.TopP == nil || *openaiReq.TopP != 0.9 { + t.Errorf("TopP = %v, want 0.9", openaiReq.TopP) + } + if openaiReq.Stream == nil || !*openaiReq.Stream { + t.Errorf("Stream = %v, want true", openaiReq.Stream) + } + stop, ok := openaiReq.Stop.([]string) + if !ok || len(stop) != 1 || stop[0] != "STOP" { + t.Errorf("Stop = %v, want [STOP]", openaiReq.Stop) + } + + // system 消息 + user + assistant + if len(openaiReq.Messages) != 3 { + t.Fatalf("got %d messages, want 3", len(openaiReq.Messages)) + } + if openaiReq.Messages[0].Role != "system" || openaiReq.Messages[0].Content != "you are helpful" { + t.Errorf("system message = %+v", openaiReq.Messages[0]) + } + if openaiReq.Messages[1].Role != "user" || openaiReq.Messages[1].Content != "hello" { + t.Errorf("user message = %+v", openaiReq.Messages[1]) + } + if openaiReq.Messages[2].Role != "assistant" || openaiReq.Messages[2].Content != "hi there" { + t.Errorf("assistant message = %+v", openaiReq.Messages[2]) + } +} + +// TestClaudeToOpenAIRequestConverter_MaxTokensDefault 未携带 max_tokens 时默认 4096。 +func TestClaudeToOpenAIRequestConverter_MaxTokensDefault(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + Messages: []dto.ClaudeMessage{{Role: "user", Content: "hi"}}, + } + + result, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + if openaiReq.MaxTokens == nil || *openaiReq.MaxTokens != 4096 { + t.Errorf("MaxTokens = %v, want default 4096", openaiReq.MaxTokens) + } +} + +// TestClaudeToOpenAIRequestConverter_Thinking thinking 启用时映射 reasoning_effort, +// 未启用(type != enabled)时不设置。 +func TestClaudeToOpenAIRequestConverter_Thinking(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + budget := 20000 + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + Thinking: &dto.ClaudeThinking{Type: "enabled", BudgetTokens: &budget}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "hi"}}, + } + + result, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + if got := result.(*dto.GeneralOpenAIRequest).ReasoningEffort; got != "high" { + t.Errorf("ReasoningEffort = %q, want high", got) + } + + claudeReq.Thinking = &dto.ClaudeThinking{Type: "disabled"} + result, err = converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + if got := result.(*dto.GeneralOpenAIRequest).ReasoningEffort; got != "" { + t.Errorf("ReasoningEffort = %q, want empty (thinking disabled)", got) + } +} + +// TestClaudeToOpenAIRequestConverter_Tools 工具定义与 tool_choice 转换。 +func TestClaudeToOpenAIRequestConverter_Tools(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + Tools: []dto.ClaudeTool{{ + Name: "get_weather", + Description: "查询天气", + InputSchema: map[string]any{"type": "object"}, + }}, + ToolChoice: map[string]any{"type": "tool", "name": "get_weather"}, + Messages: []dto.ClaudeMessage{{Role: "user", Content: "天气"}}, + } + + result, err := converter.ConvertRequest(context.Background(), newClaudeInboundMeta(), claudeReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + + if len(openaiReq.Tools) != 1 { + t.Fatalf("got %d tools, want 1", len(openaiReq.Tools)) + } + tool := openaiReq.Tools[0] + if tool.Type != "function" || tool.Function.Name != "get_weather" || tool.Function.Description != "查询天气" { + t.Errorf("tool = %+v", tool) + } + + tc, ok := openaiReq.ToolChoice.(map[string]any) + if !ok || tc["type"] != "function" { + t.Fatalf("ToolChoice = %v, want function map", openaiReq.ToolChoice) + } + fn, ok := tc["function"].(map[string]any) + if !ok || fn["name"] != "get_weather" { + t.Errorf("ToolChoice.function = %v, want name=get_weather", tc["function"]) + } +} + +func TestExtractClaudeSystemText(t *testing.T) { + t.Run("plain string", func(t *testing.T) { + if got := extractClaudeSystemText("you are helpful"); got != "you are helpful" { + t.Errorf("got %q", got) + } + }) + t.Run("array of text blocks joined", func(t *testing.T) { + sys := []any{ + map[string]any{"type": "text", "text": "line1"}, + map[string]any{"type": "text", "text": "line2"}, + } + if got := extractClaudeSystemText(sys); got != "line1\nline2" { + t.Errorf("got %q, want \"line1\\nline2\"", got) + } + }) + t.Run("non-text blocks ignored", func(t *testing.T) { + sys := []any{ + map[string]any{"type": "text", "text": "keep"}, + map[string]any{"type": "image", "text": "drop"}, + } + if got := extractClaudeSystemText(sys); got != "keep" { + t.Errorf("got %q, want \"keep\"", got) + } + }) + t.Run("unknown type returns empty", func(t *testing.T) { + if got := extractClaudeSystemText(42); got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + +func TestC2oJoinParts(t *testing.T) { + tests := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{}, ""}, + {[]string{"only"}, "only"}, + {[]string{"a", "b", "c"}, "a\nb\nc"}, + } + for _, tt := range tests { + if got := c2oJoinParts(tt.in); got != tt.want { + t.Errorf("c2oJoinParts(%v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestC2oConvertThinkingToReasoningEffort(t *testing.T) { + tests := []struct { + name string + budget *int + want string + }{ + {"nil budget defaults medium", nil, "medium"}, + {"low boundary", ptrInt(2048), "low"}, + {"low", ptrInt(1000), "low"}, + {"medium boundary", ptrInt(16384), "medium"}, + {"medium", ptrInt(8000), "medium"}, + {"high", ptrInt(16385), "high"}, + {"high large", ptrInt(50000), "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c2oConvertThinkingToReasoningEffort(&dto.ClaudeThinking{BudgetTokens: tt.budget}) + if got != tt.want { + t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want) + } + }) + } +} + +func TestC2oConvertToolChoice(t *testing.T) { + t.Run("nil", func(t *testing.T) { + if got := c2oConvertToolChoice(nil); got != nil { + t.Errorf("got %v, want nil", got) + } + }) + t.Run("string passthrough", func(t *testing.T) { + if got := c2oConvertToolChoice("auto"); got != "auto" { + t.Errorf("got %v", got) + } + }) + t.Run("type mappings", func(t *testing.T) { + cases := map[string]string{"auto": "auto", "any": "required", "none": "none"} + for in, want := range cases { + got := c2oConvertToolChoice(map[string]any{"type": in}) + if got != want { + t.Errorf("type=%q => %v, want %q", in, got, want) + } + } + }) + t.Run("specific tool maps to function", func(t *testing.T) { + got := c2oConvertToolChoice(map[string]any{"type": "tool", "name": "get_weather"}) + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", got) + } + if m["type"] != "function" { + t.Errorf("type = %v, want function", m["type"]) + } + fn, ok := m["function"].(map[string]any) + if !ok || fn["name"] != "get_weather" { + t.Errorf("function = %v, want name=get_weather", m["function"]) + } + }) + t.Run("tool without name falls back to required", func(t *testing.T) { + if got := c2oConvertToolChoice(map[string]any{"type": "tool"}); got != "required" { + t.Errorf("got %v, want required", got) + } + }) +} + +func TestConvertClaudeUserMessage_StringContent(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{Role: "user", Content: "hi there"}) + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].Role != "user" || msgs[0].Content != "hi there" { + t.Errorf("got %+v", msgs[0]) + } +} + +// TestConvertClaudeUserMessage_ToolResultArrayContent tool_result.content 为内容块数组 +// (Claude 规范形式 [{type:"text",text:"..."}])时,文本块须拼接保留, +// 否则工具结果会被替换为空字符串导致内容丢失。 +func TestConvertClaudeUserMessage_ToolResultArrayContent(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{ + Role: "user", + Content: []any{ + map[string]any{ + "type": "tool_result", + "tool_use_id": "toolu_123", + "content": []any{ + map[string]any{"type": "text", "text": "result-a"}, + map[string]any{"type": "text", "text": "result-b"}, + }, + }, + }, + }) + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + if msgs[0].Role != "tool" { + t.Errorf("role = %q, want tool", msgs[0].Role) + } + if msgs[0].ToolCallID != "toolu_123" { + t.Errorf("ToolCallID = %q, want toolu_123", msgs[0].ToolCallID) + } + if msgs[0].Content != "result-a\nresult-b" { + t.Errorf("content = %q, want %q", msgs[0].Content, "result-a\nresult-b") + } +} + +// TestConvertClaudeUserMessage_TextBeforeToolResult 文本块在 tool_result 之前时, +// 先输出 user 文本消息再输出 tool 消息。 +func TestConvertClaudeUserMessage_TextBeforeToolResult(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{ + Role: "user", + Content: []any{ + map[string]any{"type": "text", "text": "before"}, + map[string]any{"type": "tool_result", "tool_use_id": "toolu_1", "content": "result"}, + }, + }) + if len(msgs) != 2 { + t.Fatalf("got %d messages, want 2", len(msgs)) + } + if msgs[0].Role != "user" || msgs[0].Content != "before" { + t.Errorf("msg[0] = %+v", msgs[0]) + } + if msgs[1].Role != "tool" || msgs[1].Content != "result" || msgs[1].ToolCallID != "toolu_1" { + t.Errorf("msg[1] = %+v", msgs[1]) + } +} + +// TestConvertClaudeUserMessage_ImageContent base64 图片转换为 image_url 内容块。 +func TestConvertClaudeUserMessage_ImageContent(t *testing.T) { + msgs := convertClaudeUserMessage(dto.ClaudeMessage{ + Role: "user", + Content: []any{ + map[string]any{"type": "text", "text": "看这张图"}, + map[string]any{ + "type": "image", + "source": map[string]any{"type": "base64", "media_type": "image/png", "data": "abc123"}, + }, + }, + }) + if len(msgs) != 1 { + t.Fatalf("got %d messages, want 1", len(msgs)) + } + parts, ok := msgs[0].Content.([]dto.ContentPart) + if !ok || len(parts) != 2 { + t.Fatalf("content = %+v, want 2 content parts", msgs[0].Content) + } + if parts[0].Type != "text" || parts[0].Text != "看这张图" { + t.Errorf("parts[0] = %+v", parts[0]) + } + if parts[1].Type != "image_url" || parts[1].ImageURL == nil || + parts[1].ImageURL.URL != "data:image/png;base64,abc123" || parts[1].ImageURL.Detail != "auto" { + t.Errorf("parts[1] = %+v", parts[1]) + } +} + +// TestConvertClaudeAssistantMessage 文本 / thinking / tool_use 块的 assistant 消息转换。 +func TestConvertClaudeAssistantMessage(t *testing.T) { + msg := convertClaudeAssistantMessage(dto.ClaudeMessage{ + Role: "assistant", + Content: []any{ + map[string]any{"type": "thinking", "thinking": "let me think"}, + map[string]any{"type": "text", "text": "answer"}, + map[string]any{ + "type": "tool_use", "id": "toolu_9", "name": "get_weather", + "input": map[string]any{"city": "北京"}, + }, + }, + }) + if msg.Role != "assistant" { + t.Errorf("role = %q", msg.Role) + } + if msg.Content != "answer" { + t.Errorf("content = %v, want answer", msg.Content) + } + if msg.ReasoningContent == nil || *msg.ReasoningContent != "let me think" { + t.Errorf("ReasoningContent = %v, want let me think", msg.ReasoningContent) + } + if len(msg.ToolCalls) != 1 { + t.Fatalf("got %d tool calls, want 1", len(msg.ToolCalls)) + } + tc := msg.ToolCalls[0] + if tc.ID != "toolu_9" || tc.Type != "function" || tc.Function.Name != "get_weather" { + t.Errorf("tool call = %+v", tc) + } + if tc.Function.Arguments != `{"city":"北京"}` { + t.Errorf("arguments = %q", tc.Function.Arguments) + } +} diff --git a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go index 8bb21e4..6592be4 100644 --- a/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_stream.go @@ -110,9 +110,9 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse( switch event.Type { case "message_start": - // 提取模型名与初始 usage + // 提取模型名与初始 usage(模型映射时客户端只能看到原始请求模型名,不泄漏上游真实模型) if event.Message != nil { - if event.Message.Model != "" { + if event.Message.Model != "" && !isModelMapped(info) { modelName = event.Message.Model } if event.Message.Usage != nil { diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go index f35f8bc..a24e623 100644 --- a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request.go @@ -47,12 +47,7 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( upstreamModel = info.GetOriginModelName() } - // 解析 thinking 后缀,若无需保留则从模型名中剥离 - thinkingInfo := shared.ParseThinkingSuffix(upstreamModel) opts := convmeta.OptionsOf(info) - if !opts.ShouldPreserveThinkingSuffix(upstreamModel) { - upstreamModel = thinkingInfo.BaseModel - } claudeReq := &dto.ClaudeRequest{ Model: upstreamModel, @@ -110,17 +105,20 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( Role: msg.Role, } - // 转换 content + // 转换 content:字符串直转文本块;部件列表经 NormalizeContentParts 统一 + // (兼容宿主裸 unmarshal 的 []any 与链式转换构造的 []dto.ContentPart 两种真实形态) switch content := msg.Content.(type) { case string: text := content claudeMsg.Content = []dto.ClaudeContentBlock{{Type: "text", Text: &text}} - case []dto.ContentPart: - claudeMsg.Content = shared.MapOpenAIContentPartsToClaude(content) default: - // 兜底:尝试提取文本 - text := shared.MapTextContent(content) - claudeMsg.Content = []dto.ClaudeContentBlock{{Type: "text", Text: &text}} + if parts, ok := shared.NormalizeContentParts(content); ok { + claudeMsg.Content = shared.MapOpenAIContentPartsToClaude(parts) + } else { + // 兜底:尝试提取文本 + text := shared.MapTextContent(content) + claudeMsg.Content = []dto.ClaudeContentBlock{{Type: "text", Text: &text}} + } } // 转换 tool calls(带 tool_calls 的 assistant 消息) @@ -158,7 +156,6 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( // 转换 tools if len(openaiReq.Tools) > 0 { claudeReq.Tools = shared.MapOpenAIToolsToClaudeTools(openaiReq.Tools) - // 转换 tool_choice if openaiReq.ToolChoice != nil { switch tc := openaiReq.ToolChoice.(type) { @@ -187,8 +184,14 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( } } + // 服务端联网搜索:chat 的 web_search_options 映射为 Claude 的 web_search 内置工具。 + // 与自定义函数并存(Claude 允许服务端工具与 custom 工具同列),因此独立于上面的 + // tools 分支——客户端可能只要搜索、不带任何函数。 + if spec := shared.DetectWebSearchFromOpenAI(openaiReq.WebSearchOptions); spec != nil { + claudeReq.Tools = append(claudeReq.Tools, spec.ToClaudeTool()) + } + // 应用 thinking 适配器 - shared.ApplyThinkingToClaude(claudeReq, thinkingInfo, opts.Claude) return claudeReq, nil } diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go index 8d3fe1c..1b0de7c 100644 --- a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_request_test.go @@ -256,95 +256,6 @@ func TestOpenAIToClaudeRequestConverter_ToolCalls(t *testing.T) { } } -func TestOpenAIToClaudeRequestConverter_ThinkingSuffix(t *testing.T) { - converter := &OpenAIToClaudeRequestConverter{} - ctx := context.Background() - - maxTokens := 2000 - openaiReq := &dto.GeneralOpenAIRequest{ - Model: "gpt-4", - MaxTokens: &maxTokens, - Messages: []dto.Message{ - {Role: "user", Content: "Test"}, - }, - } - - tests := []struct { - name string - upstreamModel string - adapterEnabled bool - budgetPercentage float64 - expectThinking bool - expectBudgetTokens bool - }{ - { - name: "thinking suffix with adapter enabled", - upstreamModel: "claude-3-opus-20240229-thinking", - adapterEnabled: true, - expectThinking: true, - }, - { - name: "thinking suffix with budget", - upstreamModel: "claude-3-opus-20240229-thinking", - adapterEnabled: true, - budgetPercentage: 0.2, - expectThinking: true, - expectBudgetTokens: true, - }, - { - name: "thinking suffix with adapter disabled", - upstreamModel: "claude-3-opus-20240229-thinking", - adapterEnabled: false, - expectThinking: false, - }, - { - name: "nothinking suffix overrides", - upstreamModel: "claude-3-opus-20240229-nothinking", - adapterEnabled: true, - expectThinking: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - info := &mockMeta{ - upstreamModel: tt.upstreamModel, - opts: &convmeta.Options{ - Claude: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: tt.adapterEnabled, - ThinkingAdapterBudgetTokensPercentage: tt.budgetPercentage, - }, - }, - } - - result, err := converter.ConvertRequest(ctx, info, openaiReq) - if err != nil { - t.Fatalf("ConvertRequest failed: %v", err) - } - - claudeReq := result.(*dto.ClaudeRequest) - - if tt.expectThinking { - if claudeReq.Thinking == nil { - t.Error("Expected Thinking to be set, got nil") - } else if claudeReq.Thinking.Type != "enabled" { - t.Errorf("Thinking.Type = %q, want %q", claudeReq.Thinking.Type, "enabled") - } - - if tt.expectBudgetTokens { - if claudeReq.Thinking.BudgetTokens == nil { - t.Error("Expected BudgetTokens to be set, got nil") - } - } - } else { - if claudeReq.Thinking != nil { - t.Errorf("Expected Thinking to be nil, got %+v", claudeReq.Thinking) - } - } - }) - } -} - func TestOpenAIToClaudeRequestConverter_MaxTokensDefault(t *testing.T) { converter := &OpenAIToClaudeRequestConverter{} ctx := context.Background() @@ -623,3 +534,34 @@ func (m *mockMeta) AppendRequestConversion(format types.RelayFormat) { func (m *mockMeta) ConvOptions() *convmeta.Options { return m.GetOptions() } + +// TestOpenAIToClaudeRequestConverter_WebSearchOptions chat 的 web_search_options +// 必须还原为 claude 原生 web_search 工具——它是 Responses→OpenAI→Claude 链上 +// 服务端搜索能力的中间载体(codex 的 web_search 工具依赖这条链触达 claude 渠道)。 +func TestOpenAIToClaudeRequestConverter_WebSearchOptions(t *testing.T) { + converter := &OpenAIToClaudeRequestConverter{} + opts := json.RawMessage(`{"search_context_size":"medium"}`) + out, err := converter.ConvertRequest(context.Background(), &convmeta.Values{}, &dto.GeneralOpenAIRequest{ + Model: "claude-sonnet-5", + Messages: []dto.Message{{Role: "user", Content: "今天有什么新闻"}}, + MaxTokens: intPtr(4096), + WebSearchOptions: opts, + }) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + claudeReq, ok := out.(*dto.ClaudeRequest) + if !ok { + t.Fatalf("输出类型 %T", out) + } + found := false + for _, tool := range claudeReq.Tools { + if tool.Type == "web_search_20250305" && tool.Name == "web_search" { + found = true + break + } + } + if !found { + t.Errorf("web_search_options 应还原为 claude 原生 web_search 工具,实际 tools: %+v", claudeReq.Tools) + } +} diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response.go new file mode 100644 index 0000000..302394d --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response.go @@ -0,0 +1,179 @@ +package oai_chat + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToClaudeResponseConverter 将 OpenAI Chat Completions 响应转换为 Claude Messages API 响应。 +type OpenAIToClaudeResponseConverter struct{} + +func (c *OpenAIToClaudeResponseConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToClaudeMessages +} + +func (c *OpenAIToClaudeResponseConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToClaudeResponseConverter) To() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *OpenAIToClaudeResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +func (c *OpenAIToClaudeResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + openaiResp, ok := response.(*dto.ChatCompletionResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.ChatCompletionResponse, got %T", response) + } + + content := make([]dto.ClaudeContentBlock, 0) + var textParts []string + var thinkingParts []string + var toolCalls []dto.ClaudeContentBlock + + if len(openaiResp.Choices) > 0 { + choice := openaiResp.Choices[0] + + // 提取文本 + if text, ok := choice.Message.Content.(string); ok && text != "" { + textParts = append(textParts, text) + } + + // 提取思维内容 + if choice.Message.ReasoningContent != nil && *choice.Message.ReasoningContent != "" { + thinkingParts = append(thinkingParts, *choice.Message.ReasoningContent) + } + + // 提取工具调用 + for _, tc := range choice.Message.ToolCalls { + var inputObj any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &inputObj); err != nil { + inputObj = map[string]any{} + } + toolCalls = append(toolCalls, dto.ClaudeContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: inputObj, + }) + } + } + + // 添加思维块(如果有) + for _, thinking := range thinkingParts { + content = append(content, dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(thinking), + }) + } + + // 添加文本块 + for _, text := range textParts { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(text), + }) + } + + // 添加工具调用块 + content = append(content, toolCalls...) + + if len(content) == 0 { + content = append(content, dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(""), + }) + } + + modelName := openaiResp.Model + if isModelMapped(info) { + modelName = info.GetOriginModelName() + } + + stopReason := "end_turn" + if len(openaiResp.Choices) > 0 { + stopReason = mapOpenAIFinishReasonToClaude(openaiResp.Choices[0].FinishReason) + } + + // OpenAI 的 prompt_tokens 已含 cached(子集语义),Claude 的 input_tokens 不含 cache_read, + // 需扣减后映射,否则 Claude 客户端按本协议语义计账时缓存部分被重复计入。 + // PromptTokensDetails 可能为 nil(多数 OpenAI 兼容上游不返回该字段),必须判空。 + cachedTokens := 0 + if openaiResp.Usage.PromptTokensDetails != nil { + cachedTokens = openaiResp.Usage.PromptTokensDetails.CachedTokens + } + inputTokens := openaiResp.Usage.PromptTokens - cachedTokens + if inputTokens < 0 { + inputTokens = 0 + } + + return &dto.ClaudeResponse{ + ID: generateClaudeMessageID(), + Type: "message", + Role: "assistant", + Content: content, + StopReason: stopReason, + StopSequence: nil, + Model: modelName, + Usage: &dto.ClaudeUsage{ + InputTokens: inputTokens, + OutputTokens: openaiResp.Usage.CompletionTokens, + CacheReadInputTokens: cachedTokens, + }, + }, nil +} + +// mapOpenAIFinishReasonToClaude 将 OpenAI finish_reason 映射为 Claude stop_reason。 +func mapOpenAIFinishReasonToClaude(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: + if reason != "" { + return reason + } + return "end_turn" + } +} + +// isModelMapped 判断是否经过模型名映射(等价于宿主 ChannelMeta.IsModelMapped: +// 上游模型名非空且与入站模型名不同)。 +func isModelMapped(info convmeta.Meta) bool { + if info == nil || !info.HasChannelMeta() { + return false + } + upstream := info.GetUpstreamModelName() + return upstream != "" && upstream != info.GetOriginModelName() +} + +// generateClaudeMessageID 生成 Claude 消息 ID(与本包 generateResponseID 相同的派生方式)。 +func generateClaudeMessageID() string { + return fmt.Sprintf("msg_%d", getCurrentTimestamp()) +} + +// strPtr 返回 string 的指针 +func strPtr(v string) *string { + return &v +} + +// 转换器在宿主应用的包初始化阶段注册,而非在此 internal 实现包中完成。 diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response_test.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response_test.go new file mode 100644 index 0000000..7ebbc0d --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_response_test.go @@ -0,0 +1,249 @@ +package oai_chat + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +func TestOpenAIToClaudeResponseConverter_Metadata(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + + if converter.ID() != relayconvert.ResponseConverterOAIChatToClaudeMessages { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ResponseConverterOAIChatToClaudeMessages) + } + if converter.From() != types.RelayFormatOpenAI { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatOpenAI) + } + if converter.To() != types.RelayFormatClaude { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatClaude) + } + if converter.Quality() != relayconvert.ResponseConverterQualityGood { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.ResponseConverterQualityGood) + } +} + +func TestOpenAIToClaudeResponseConverter_InvalidType(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + if _, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), "not a response"); err == nil { + t.Fatal("expected type assertion error, got nil") + } +} + +// TestOpenAIToClaudeResponseConverter_UsageNilDetails 上游不返回 prompt_tokens_details +// (多数 OpenAI 兼容上游)时不得空指针 panic,且无缓存可扣减。 +func TestOpenAIToClaudeResponseConverter_UsageNilDetails(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-1", + Model: "gpt-4o", + Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, + Usage: dto.UsageWithDetails{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}, + } + + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + resp := result.(*dto.ClaudeResponse) + + if resp.Usage.InputTokens != 100 { + t.Errorf("InputTokens = %d, want 100 (no cache to deduct)", resp.Usage.InputTokens) + } + if resp.Usage.CacheReadInputTokens != 0 { + t.Errorf("CacheReadInputTokens = %d, want 0", resp.Usage.CacheReadInputTokens) + } + if resp.Usage.OutputTokens != 20 { + t.Errorf("OutputTokens = %d, want 20", resp.Usage.OutputTokens) + } +} + +// TestOpenAIToClaudeResponseConverter_UsageWithCache 上游返回缓存明细时,usage 需按 Claude 语义映射: +// OpenAI 的 prompt_tokens 含 cached(子集),Claude 的 input_tokens 不含 cache_read, +// 直接透传会导致客户端按本协议语义计账时缓存部分重复计入。 +func TestOpenAIToClaudeResponseConverter_UsageWithCache(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-2", + Model: "gpt-4o", + Choices: []dto.Choice{{Index: 0, Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, + Usage: dto.UsageWithDetails{ + PromptTokens: 100, + CompletionTokens: 20, + TotalTokens: 120, + PromptTokensDetails: &dto.TokenDetails{CachedTokens: 30}, + }, + } + + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + resp := result.(*dto.ClaudeResponse) + + if resp.Usage.InputTokens != 70 { + t.Errorf("InputTokens = %d, want 70 (prompt 100 - cached 30)", resp.Usage.InputTokens) + } + if resp.Usage.CacheReadInputTokens != 30 { + t.Errorf("CacheReadInputTokens = %d, want 30", resp.Usage.CacheReadInputTokens) + } +} + +// TestOpenAIToClaudeResponseConverter_ContentBlocks thinking / text / tool_use 块按序生成。 +func TestOpenAIToClaudeResponseConverter_ContentBlocks(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + reasoning := "let me think" + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-3", + Model: "gpt-4o", + Choices: []dto.Choice{{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: "answer", + ReasoningContent: &reasoning, + ToolCalls: []dto.ToolCall{{ + ID: "call_1", Type: "function", + Function: dto.FunctionCall{Name: "get_weather", Arguments: `{"city":"北京"}`}, + }}, + }, + FinishReason: "tool_calls", + }}, + } + + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + resp := result.(*dto.ClaudeResponse) + + if resp.Type != "message" || resp.Role != "assistant" { + t.Errorf("type/role = %q/%q", resp.Type, resp.Role) + } + if !strings.HasPrefix(resp.ID, "msg_") { + t.Errorf("ID = %q, want msg_ prefix", resp.ID) + } + if len(resp.Content) != 3 { + t.Fatalf("got %d content blocks, want 3 (thinking, text, tool_use)", len(resp.Content)) + } + if resp.Content[0].Type != "thinking" || resp.Content[0].Thinking == nil || *resp.Content[0].Thinking != "let me think" { + t.Errorf("block[0] = %+v, want thinking", resp.Content[0]) + } + if resp.Content[1].Type != "text" || resp.Content[1].Text == nil || *resp.Content[1].Text != "answer" { + t.Errorf("block[1] = %+v, want text", resp.Content[1]) + } + if resp.Content[2].Type != "tool_use" || resp.Content[2].ID != "call_1" || resp.Content[2].Name != "get_weather" { + t.Errorf("block[2] = %+v, want tool_use", resp.Content[2]) + } + input, ok := resp.Content[2].Input.(map[string]any) + if !ok || input["city"] != "北京" { + t.Errorf("tool_use input = %v", resp.Content[2].Input) + } + if resp.StopReason != "tool_use" { + t.Errorf("StopReason = %q, want tool_use", resp.StopReason) + } +} + +// TestOpenAIToClaudeResponseConverter_BadToolArguments 工具参数非法 JSON 时兜底为空对象。 +func TestOpenAIToClaudeResponseConverter_BadToolArguments(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + openaiResp := &dto.ChatCompletionResponse{ + Model: "gpt-4o", + Choices: []dto.Choice{{ + Message: dto.Message{ + Role: "assistant", + ToolCalls: []dto.ToolCall{{ + ID: "call_bad", Type: "function", + Function: dto.FunctionCall{Name: "fn", Arguments: "{invalid"}, + }}, + }, + FinishReason: "tool_calls", + }}, + } + + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + resp := result.(*dto.ClaudeResponse) + if len(resp.Content) != 1 || resp.Content[0].Type != "tool_use" { + t.Fatalf("content = %+v", resp.Content) + } + input, ok := resp.Content[0].Input.(map[string]any) + if !ok || len(input) != 0 { + t.Errorf("input = %v, want empty map", resp.Content[0].Input) + } +} + +// TestOpenAIToClaudeResponseConverter_EmptyContent 空响应兜底为单个空文本块。 +func TestOpenAIToClaudeResponseConverter_EmptyContent(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + openaiResp := &dto.ChatCompletionResponse{Model: "gpt-4o"} + + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + resp := result.(*dto.ClaudeResponse) + if len(resp.Content) != 1 || resp.Content[0].Type != "text" || resp.Content[0].Text == nil || *resp.Content[0].Text != "" { + t.Errorf("content = %+v, want single empty text block", resp.Content) + } + // 无 choices 时 stop_reason 兜底 end_turn + if resp.StopReason != "end_turn" { + t.Errorf("StopReason = %q, want end_turn", resp.StopReason) + } +} + +// TestOpenAIToClaudeResponseConverter_ModelName 模型映射时回显入站模型名, +// 未映射时透传上游响应模型名。 +func TestOpenAIToClaudeResponseConverter_ModelName(t *testing.T) { + converter := &OpenAIToClaudeResponseConverter{} + openaiResp := &dto.ChatCompletionResponse{ + Model: "gpt-4o-2024-08-06", + Choices: []dto.Choice{{Message: dto.Message{Role: "assistant", Content: "hi"}, FinishReason: "stop"}}, + } + + // 已映射(origin != upstream)→ 回显 origin + result, err := converter.ConvertResponse(context.Background(), newClaudeInboundMeta(), openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + if got := result.(*dto.ClaudeResponse).Model; got != "claude-sonnet-4" { + t.Errorf("mapped model = %q, want claude-sonnet-4", got) + } + + // 未映射(origin == upstream)→ 透传响应模型名 + unmapped := &convmeta.Values{ + OriginModelName: "gpt-4o", + UpstreamModelName: "gpt-4o", + ChannelMetaAttached: true, + } + result, err = converter.ConvertResponse(context.Background(), unmapped, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse error: %v", err) + } + if got := result.(*dto.ClaudeResponse).Model; got != "gpt-4o-2024-08-06" { + t.Errorf("unmapped model = %q, want gpt-4o-2024-08-06", got) + } +} + +func TestMapOpenAIFinishReasonToClaude(t *testing.T) { + tests := map[string]string{ + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "content_filter": "refusal", + "": "end_turn", + "custom_reason": "custom_reason", // 未知值原样透传 + } + for in, want := range tests { + if got := mapOpenAIFinishReasonToClaude(in); got != want { + t.Errorf("mapOpenAIFinishReasonToClaude(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream.go new file mode 100644 index 0000000..1cdd65c --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream.go @@ -0,0 +1,371 @@ +package oai_chat + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToClaudeStreamConverter 将 OpenAI Chat Completions SSE 流转换为 Claude Messages 事件流。 +// 每个 Claude 事件通过 chunkWriter 以 *relayconvert.StreamEvent 封装输出 +// (Event=事件名,Data=事件负载;带 usage 的事件同时填 Usage 供宿主捕获用量)。 +type OpenAIToClaudeStreamConverter struct{} + +func (c *OpenAIToClaudeStreamConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToClaudeMessagesStream +} + +func (c *OpenAIToClaudeStreamConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToClaudeStreamConverter) To() types.RelayFormat { + return types.RelayFormatClaude +} + +func (c *OpenAIToClaudeStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 读取上游 OpenAI SSE 流(data: {...} 行 + data: [DONE] 结束), +// 转换为 Claude 事件序列:message_start / content_block_start / content_block_delta / +// content_block_stop / message_delta / message_stop。 +// 宿主侧职责(SSE 帧化、ping、首字节时间、流中断结算)不在此处理。 +func (c *OpenAIToClaudeStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + msgID := generateClaudeMessageID() + modelName := "" + if info != nil { + modelName = info.GetOriginModelName() + if isModelMapped(info) { + modelName = info.GetUpstreamModelName() + } + } + + // writeEvent 输出一帧 Claude 事件(usage 仅在需要宿主捕获用量的事件上携带) + writeEvent := func(eventType string, data *dto.ClaudeResponse, usage *dto.UsageWithDetails) error { + return chunkWriter(&relayconvert.StreamEvent{Event: eventType, Data: data, Usage: usage}) + } + + var ( + startSent bool + doneSent bool + finishReason string + contentIndex int + inputTokens int + outputTokens int + cachedTokens int // prompt_tokens_details.cached_tokens(OpenAI 口径:已含于 prompt_tokens) + promptDetails *dto.TokenDetails // 缓存等输入明细,透传给宿主计费(按明细扣减缓存价) + completionDetails *dto.TokenDetails // 思考等输出明细,透传给宿主计费 + currentBlockType string // 跟踪当前 block 类型: "text" / "thinking" / "tool_use" + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + + if data == "[DONE]" { + // 关闭当前 content block(如果有的话) + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + if err := writeEvent("content_block_stop", &blockStop, nil); err != nil { + return err + } + contentIndex++ + currentBlockType = "" + } + + // 发送 message_delta(stop_reason)+ message_stop。 + // Claude 语义:input_tokens 不含 cache_read(OpenAI 的 prompt_tokens 含 cached,扣减后映射); + // OpenAI 的 usage 在流尾才返回、message_start 时不可得,最终用量在此补报 + reason := mapOpenAIFinishReasonToClaude(finishReason) + deltaInput := inputTokens - cachedTokens + if deltaInput < 0 { + deltaInput = 0 + } + delta := dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeDelta{ + StopReason: strPtr(reason), + }, + Usage: &dto.ClaudeUsage{ + InputTokens: deltaInput, + CacheReadInputTokens: cachedTokens, + OutputTokens: outputTokens, + }, + } + // 宿主捕获的用量保持 OpenAI 原始口径(prompt 含 cached)并携带明细, + // 缓存扣减由宿主计费侧按明细处理,避免缓存按 input 全价计费 + if err := writeEvent("message_delta", &delta, &dto.UsageWithDetails{ + PromptTokens: inputTokens, + CompletionTokens: outputTokens, + TotalTokens: inputTokens + outputTokens, + PromptTokensDetails: promptDetails, + CompletionTokenDetails: completionDetails, + }); err != nil { + return err + } + + stopEvent := dto.ClaudeResponse{Type: "message_stop"} + if err := writeEvent("message_stop", &stopEvent, nil); err != nil { + return err + } + + doneSent = true + break + } + + var streamResp dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + // 提取 usage(OpenAI usage 通常在最后一个 chunk) + if streamResp.Usage != nil { + inputTokens = streamResp.Usage.PromptTokens + outputTokens = streamResp.Usage.CompletionTokens + if streamResp.Usage.PromptTokensDetails != nil { + cachedTokens = streamResp.Usage.PromptTokensDetails.CachedTokens + } + // 缓存/思考明细透传给宿主计费;OpenAI 的 prompt_tokens 含 cached(子集语义), + // 宿主计费按明细扣减缓存部分,避免缓存按 input 全价计费 + promptDetails = streamResp.Usage.PromptTokensDetails + completionDetails = streamResp.Usage.CompletionTokenDetails + } + + // 发送 message_start(首块数据时) + if !startSent { + startEvent := dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMessageInfo{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []dto.ClaudeContentBlock{}, + Model: modelName, + StopReason: nil, + StopSequence: nil, + Usage: &dto.ClaudeUsage{ + InputTokens: inputTokens, + OutputTokens: 0, + }, + }, + } + if err := writeEvent("message_start", &startEvent, nil); err != nil { + return err + } + startSent = true + } + + // 处理每个 choice delta + for _, choice := range streamResp.Choices { + // 文本内容 + if text, ok := choice.Delta.Content.(string); ok && text != "" { + // 如果当前 block 不是 text,先关闭前一个 block + if currentBlockType != "" && currentBlockType != "text" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + if err := writeEvent("content_block_stop", &blockStop, nil); err != nil { + return err + } + contentIndex++ + } + + // content_block_start(首次文本输出) + if currentBlockType != "text" { + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "text", + Text: strPtr(""), + }, + } + if err := writeEvent("content_block_start", &blockStart, nil); err != nil { + return err + } + currentBlockType = "text" + } + + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "text_delta", + Text: strPtr(text), + }, + } + if err := writeEvent("content_block_delta", &delta, nil); err != nil { + return err + } + } + + // 推理内容(thinking) + if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + // 如果当前 block 不是 thinking,先关闭前一个 block + if currentBlockType != "" && currentBlockType != "thinking" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + if err := writeEvent("content_block_stop", &blockStop, nil); err != nil { + return err + } + contentIndex++ + } + + if currentBlockType != "thinking" { + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "thinking", + Thinking: strPtr(""), + }, + } + if err := writeEvent("content_block_start", &blockStart, nil); err != nil { + return err + } + currentBlockType = "thinking" + } + + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "thinking_delta", + Thinking: choice.Delta.ReasoningContent, + }, + } + if err := writeEvent("content_block_delta", &delta, nil); err != nil { + return err + } + } + + // 工具调用 + for _, tc := range choice.Delta.ToolCalls { + // content_block_start 用于 tool_use(仅在 function name 出现时) + if tc.Function.Name != "" { + // 先关闭前一个 block + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + if err := writeEvent("content_block_stop", &blockStop, nil); err != nil { + return err + } + contentIndex++ + } + + blockStart := dto.ClaudeResponse{ + Type: "content_block_start", + Index: intPtr(contentIndex), + ContentBlock: &dto.ClaudeContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: map[string]any{}, + }, + } + if err := writeEvent("content_block_start", &blockStart, nil); err != nil { + return err + } + currentBlockType = "tool_use" + } + + // content_block_delta 用于工具参数 + if tc.Function.Arguments != "" { + args := tc.Function.Arguments + delta := dto.ClaudeResponse{ + Type: "content_block_delta", + Index: intPtr(contentIndex), + Delta: &dto.ClaudeDelta{ + Type: "input_json_delta", + PartialJSON: &args, + }, + } + if err := writeEvent("content_block_delta", &delta, nil); err != nil { + return err + } + } + } + + // finish_reason + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + return fmt.Errorf("stream scanner error: %w", err) + } + + if !doneSent { + // 流意外结束(未收到 [DONE]),发送终止事件; + // 已收到的 usage 附在 message_stop 上供宿主捕获,避免整段按零值计费漏计 + if currentBlockType != "" { + blockStop := dto.ClaudeResponse{ + Type: "content_block_stop", + Index: intPtr(contentIndex), + } + if err := writeEvent("content_block_stop", &blockStop, nil); err != nil { + return err + } + } + stopEvent := dto.ClaudeResponse{Type: "message_stop"} + if err := writeEvent("message_stop", &stopEvent, &dto.UsageWithDetails{ + PromptTokens: inputTokens, + CompletionTokens: outputTokens, + TotalTokens: inputTokens + outputTokens, + PromptTokensDetails: promptDetails, + CompletionTokenDetails: completionDetails, + }); err != nil { + return err + } + } + + return nil +} + +// intPtr 返回 int 的指针 +func intPtr(v int) *int { + return &v +} + +// 转换器在宿主应用的包初始化阶段注册,而非在此 internal 实现包中完成。 diff --git a/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream_test.go b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream_test.go new file mode 100644 index 0000000..d2c0a53 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/openai_to_claude_stream_test.go @@ -0,0 +1,389 @@ +package oai_chat + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// collectClaudeStreamEvents 驱动流式转换器并收集输出的 StreamEvent 序列。 +func collectClaudeStreamEvents(t *testing.T, sse string, info convmeta.Meta) ([]*relayconvert.StreamEvent, error) { + t.Helper() + converter := &OpenAIToClaudeStreamConverter{} + var events []*relayconvert.StreamEvent + err := converter.ConvertStreamResponse(context.Background(), info, strings.NewReader(sse), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("expected *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +// eventNames 提取事件名序列。 +func eventNames(events []*relayconvert.StreamEvent) []string { + names := make([]string, 0, len(events)) + for _, ev := range events { + names = append(names, ev.Event) + } + return names +} + +// claudePayload 断言事件负载为 *dto.ClaudeResponse 并返回。 +func claudePayload(t *testing.T, ev *relayconvert.StreamEvent) *dto.ClaudeResponse { + t.Helper() + resp, ok := ev.Data.(*dto.ClaudeResponse) + if !ok { + t.Fatalf("event %q payload = %T, want *dto.ClaudeResponse", ev.Event, ev.Data) + } + return resp +} + +func TestOpenAIToClaudeStreamConverter_Metadata(t *testing.T) { + converter := &OpenAIToClaudeStreamConverter{} + + if converter.ID() != relayconvert.ResponseConverterOAIChatToClaudeMessagesStream { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ResponseConverterOAIChatToClaudeMessagesStream) + } + if converter.From() != types.RelayFormatOpenAI { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatOpenAI) + } + if converter.To() != types.RelayFormatClaude { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatClaude) + } + if converter.Quality() != relayconvert.ResponseConverterQualityGood { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.ResponseConverterQualityGood) + } +} + +// TestOpenAIToClaudeStreamConverter_BasicTextStream 纯文本流:事件序列与负载逐项校验, +// 缓存明细按 Claude 语义在 message_delta 补报(input 扣减 cached), +// 宿主捕获的 usage 保持 OpenAI 原始口径且带明细。 +func TestOpenAIToClaudeStreamConverter_BasicTextStream(t *testing.T) { + sse := `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"}}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":" there"}}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":30},"completion_tokens_details":{"reasoning_tokens":5}}} + +data: [DONE] + +` + events, err := collectClaudeStreamEvents(t, sse, newClaudeInboundMeta()) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"} + got := eventNames(events) + if len(got) != len(want) { + t.Fatalf("event sequence = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } + + // message_start:msg_ 前缀 ID、assistant 角色、映射后的模型名(IsModelMapped → upstream) + start := claudePayload(t, events[0]) + if start.Message == nil || start.Message.Role != "assistant" || start.Message.Type != "message" { + t.Errorf("message_start payload = %+v", start.Message) + } + if !strings.HasPrefix(start.Message.ID, "msg_") { + t.Errorf("message ID = %q, want msg_ prefix", start.Message.ID) + } + if start.Message.Model != "gpt-4o" { + t.Errorf("message model = %q, want gpt-4o (model mapped → upstream)", start.Message.Model) + } + + // content_block_start:text 块从空文本开始 + blockStart := claudePayload(t, events[1]) + if blockStart.ContentBlock == nil || blockStart.ContentBlock.Type != "text" { + t.Errorf("content_block_start = %+v", blockStart.ContentBlock) + } + if blockStart.Index == nil || *blockStart.Index != 0 { + t.Errorf("content_block_start index = %v, want 0", blockStart.Index) + } + + // 文本 delta + delta1 := claudePayload(t, events[2]) + if delta1.Delta == nil || delta1.Delta.Type != "text_delta" || delta1.Delta.Text == nil || *delta1.Delta.Text != "Hi" { + t.Errorf("delta[0] = %+v", delta1.Delta) + } + delta2 := claudePayload(t, events[3]) + if delta2.Delta == nil || delta2.Delta.Text == nil || *delta2.Delta.Text != " there" { + t.Errorf("delta[1] = %+v", delta2.Delta) + } + + // message_delta:Claude 语义补报(input 扣减 cached)+ stop_reason 映射 + msgDelta := claudePayload(t, events[5]) + if msgDelta.Delta == nil || msgDelta.Delta.StopReason == nil || *msgDelta.Delta.StopReason != "end_turn" { + t.Errorf("message_delta stop_reason = %+v, want end_turn", msgDelta.Delta) + } + if msgDelta.Usage == nil { + t.Fatal("message_delta usage missing") + } + if msgDelta.Usage.InputTokens != 70 { + t.Errorf("message_delta input_tokens = %d, want 70 (prompt 100 - cached 30)", msgDelta.Usage.InputTokens) + } + if msgDelta.Usage.CacheReadInputTokens != 30 { + t.Errorf("message_delta cache_read_input_tokens = %d, want 30", msgDelta.Usage.CacheReadInputTokens) + } + if msgDelta.Usage.OutputTokens != 20 { + t.Errorf("message_delta output_tokens = %d, want 20", msgDelta.Usage.OutputTokens) + } + + // 宿主捕获 usage:OpenAI 原始口径 + 明细透传 + if events[5].Usage == nil { + t.Fatal("message_delta StreamEvent.Usage missing") + } + hostUsage := events[5].Usage + if hostUsage.PromptTokens != 100 || hostUsage.CompletionTokens != 20 || hostUsage.TotalTokens != 120 { + t.Errorf("host usage = %+v, want prompt=100 completion=20 total=120", hostUsage) + } + if hostUsage.PromptTokensDetails == nil || hostUsage.PromptTokensDetails.CachedTokens != 30 { + t.Errorf("host usage prompt details = %+v, want cached=30", hostUsage.PromptTokensDetails) + } + if hostUsage.CompletionTokenDetails == nil || hostUsage.CompletionTokenDetails.ReasoningTokens != 5 { + t.Errorf("host usage completion details = %+v, want reasoning=5", hostUsage.CompletionTokenDetails) + } + + // 非 usage 事件不携带宿主用量 + for i, ev := range events { + if i != 5 && ev.Usage != nil { + t.Errorf("event[%d] %q carries unexpected usage", i, ev.Event) + } + } +} + +// TestOpenAIToClaudeStreamConverter_ToolCallStream 文本后接工具调用: +// 关闭文本块 → tool_use content_block_start(含 id/name/空 input)→ input_json_delta。 +func TestOpenAIToClaudeStreamConverter_ToolCallStream(t *testing.T) { + sse := `data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"北京\"}"}}]}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] + +` + events, err := collectClaudeStreamEvents(t, sse, newClaudeInboundMeta()) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + want := []string{ + "message_start", + "content_block_start", // text + "content_block_delta", // "ok" + "content_block_stop", // 关闭 text 块 + "content_block_start", // tool_use + "content_block_delta", // args part1 + "content_block_delta", // args part2 + "content_block_stop", // [DONE] 关闭 tool_use 块 + "message_delta", + "message_stop", + } + got := eventNames(events) + if len(got) != len(want) { + t.Fatalf("event sequence = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } + + // tool_use 块(index 1):id/name/空 input + toolStart := claudePayload(t, events[4]) + if toolStart.Index == nil || *toolStart.Index != 1 { + t.Errorf("tool_use index = %v, want 1", toolStart.Index) + } + cb := toolStart.ContentBlock + if cb == nil || cb.Type != "tool_use" || cb.ID != "call_1" || cb.Name != "get_weather" { + t.Errorf("tool_use block = %+v", cb) + } + if input, ok := cb.Input.(map[string]any); !ok || len(input) != 0 { + t.Errorf("tool_use input = %v, want empty map", cb.Input) + } + + // input_json_delta 参数分片 + argsDelta := claudePayload(t, events[5]) + if argsDelta.Delta == nil || argsDelta.Delta.Type != "input_json_delta" || + argsDelta.Delta.PartialJSON == nil || *argsDelta.Delta.PartialJSON != `{"city":` { + t.Errorf("input_json_delta[0] = %+v", argsDelta.Delta) + } + argsDelta2 := claudePayload(t, events[6]) + if argsDelta2.Delta == nil || argsDelta2.Delta.PartialJSON == nil || *argsDelta2.Delta.PartialJSON != `"北京"}` { + t.Errorf("input_json_delta[1] = %+v", argsDelta2.Delta) + } + + // finish_reason=tool_calls → stop_reason=tool_use + msgDelta := claudePayload(t, events[8]) + if msgDelta.Delta == nil || msgDelta.Delta.StopReason == nil || *msgDelta.Delta.StopReason != "tool_use" { + t.Errorf("stop_reason = %+v, want tool_use", msgDelta.Delta) + } +} + +// TestOpenAIToClaudeStreamConverter_ThinkingStream reasoning_content 映射为 thinking 块, +// 思考转正文时补 content_block_stop / content_block_start。 +func TestOpenAIToClaudeStreamConverter_ThinkingStream(t *testing.T) { + sse := `data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"thinking..."}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"answer"}}]} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] + +` + events, err := collectClaudeStreamEvents(t, sse, newClaudeInboundMeta()) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + want := []string{ + "message_start", + "content_block_start", // thinking + "content_block_delta", // thinking_delta + "content_block_stop", // 关闭 thinking 块 + "content_block_start", // text + "content_block_delta", // text_delta + "content_block_stop", // [DONE] 关闭 text 块 + "message_delta", + "message_stop", + } + got := eventNames(events) + if len(got) != len(want) { + t.Fatalf("event sequence = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } + + thinkStart := claudePayload(t, events[1]) + if thinkStart.ContentBlock == nil || thinkStart.ContentBlock.Type != "thinking" { + t.Errorf("thinking block start = %+v", thinkStart.ContentBlock) + } + thinkDelta := claudePayload(t, events[2]) + if thinkDelta.Delta == nil || thinkDelta.Delta.Type != "thinking_delta" || + thinkDelta.Delta.Thinking == nil || *thinkDelta.Delta.Thinking != "thinking..." { + t.Errorf("thinking_delta = %+v", thinkDelta.Delta) + } + textStart := claudePayload(t, events[4]) + if textStart.ContentBlock == nil || textStart.ContentBlock.Type != "text" { + t.Errorf("text block start = %+v", textStart.ContentBlock) + } + if textStart.Index == nil || *textStart.Index != 1 { + t.Errorf("text block index = %v, want 1", textStart.Index) + } +} + +// TestOpenAIToClaudeStreamConverter_UnexpectedEnd 上游未发 [DONE] 即断流: +// 补发 content_block_stop + message_stop,已收到的 usage 附在 message_stop 上供宿主捕获。 +func TestOpenAIToClaudeStreamConverter_UnexpectedEnd(t *testing.T) { + sse := `data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"partial"}}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}} + +` + events, err := collectClaudeStreamEvents(t, sse, newClaudeInboundMeta()) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + + want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_stop"} + got := eventNames(events) + if len(got) != len(want) { + t.Fatalf("event sequence = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], want[i], got) + } + } + + last := events[len(events)-1] + if last.Usage == nil { + t.Fatal("message_stop should carry usage on unexpected stream end") + } + if last.Usage.PromptTokens != 10 || last.Usage.CompletionTokens != 2 || last.Usage.TotalTokens != 12 { + t.Errorf("usage = %+v, want prompt=10 completion=2 total=12", last.Usage) + } +} + +// TestOpenAIToClaudeStreamConverter_MalformedJSONSkipped 非法 JSON 行跳过不中断。 +func TestOpenAIToClaudeStreamConverter_MalformedJSONSkipped(t *testing.T) { + sse := `data: {not valid json} + +data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]} + +data: [DONE] + +` + events, err := collectClaudeStreamEvents(t, sse, newClaudeInboundMeta()) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + got := eventNames(events) + want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_stop", "message_delta", "message_stop"} + if len(got) != len(want) { + t.Fatalf("event sequence = %v, want %v", got, want) + } +} + +// TestOpenAIToClaudeStreamConverter_ModelNotMapped 未映射时 message_start 回显入站模型名。 +func TestOpenAIToClaudeStreamConverter_ModelNotMapped(t *testing.T) { + sse := `data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"x"}}]} + +data: [DONE] + +` + info := &convmeta.Values{ + OriginModelName: "gpt-4o", + UpstreamModelName: "gpt-4o", + ChannelMetaAttached: true, + } + events, err := collectClaudeStreamEvents(t, sse, info) + if err != nil { + t.Fatalf("ConvertStreamResponse error: %v", err) + } + start := claudePayload(t, events[0]) + if start.Message == nil || start.Message.Model != "gpt-4o" { + t.Errorf("model = %+v, want gpt-4o", start.Message) + } +} + +// TestOpenAIToClaudeStreamConverter_ContextCancellation ctx 取消时返回 ctx.Err()。 +func TestOpenAIToClaudeStreamConverter_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + sse := `data: {"id":"c","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"x"}}]} + +data: [DONE] + +` + converter := &OpenAIToClaudeStreamConverter{} + err := converter.ConvertStreamResponse(ctx, newClaudeInboundMeta(), strings.NewReader(sse), func(chunk any) error { + return nil + }) + if err != context.Canceled { + t.Errorf("err = %v, want context.Canceled", err) + } +} diff --git a/relaykit/relayconvert/internal/oai_gemini/edge_test.go b/relaykit/relayconvert/internal/oai_gemini/edge_test.go index 92ee973..0597eba 100644 --- a/relaykit/relayconvert/internal/oai_gemini/edge_test.go +++ b/relaykit/relayconvert/internal/oai_gemini/edge_test.go @@ -83,54 +83,36 @@ func TestParseDataURL(t *testing.T) { assert.False(t, ok) } -func TestMapSchemaType(t *testing.T) { - cases := map[string]string{ - "string": "STRING", "number": "NUMBER", "integer": "INTEGER", - "boolean": "BOOLEAN", "object": "OBJECT", "array": "ARRAY", "null": "NULL", - "custom": "custom", // default 原样返回 - } - for in, want := range cases { - assert.Equal(t, want, mapSchemaType(in), "input=%q", in) - } -} - -func TestConvertSchemaMap(t *testing.T) { - // 非 map 原样返回 - assert.Equal(t, "x", convertSchemaMap("x")) - - in := map[string]any{ - "type": "string", - "properties": map[string]any{"a": map[string]any{"type": "integer"}}, - "items": map[string]any{"type": "boolean"}, - "anyOf": []any{map[string]any{"type": "null"}}, - "extra": "keep", - } - got := convertSchemaMap(in).(map[string]any) - assert.Equal(t, "STRING", got["type"]) - assert.Equal(t, "keep", got["extra"]) - props := got["properties"].(map[string]any) - assert.Equal(t, "INTEGER", props["a"].(map[string]any)["type"]) - assert.Equal(t, "BOOLEAN", got["items"].(map[string]any)["type"]) - anyOf := got["anyOf"].([]any) - assert.Equal(t, "NULL", anyOf[0].(map[string]any)["type"]) -} - func TestConvertResponseSchema(t *testing.T) { - // nil → nil assert.Nil(t, convertResponseSchema(nil)) - // json_schema 包装 + // json_schema 包装({name,schema,strict})解包为裸 schema:带着 name 外壳发出会被 + // protojson 拒绝(Unknown name "name" at 'request.generation_config.response_schema') got := convertResponseSchema(map[string]any{ - "json_schema": map[string]any{ - "schema": map[string]any{"type": "object"}, + "name": "weather", + "schema": map[string]any{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}}, + }) + m := got.(map[string]any) + assert.Equal(t, "object", m["type"]) + assert.NotContains(t, m, "name") + props := m["properties"].(map[string]any) + assert.Equal(t, "string", props["city"].(map[string]any)["type"]) + + // 裸 schema(无包装)原样归一化 + got = convertResponseSchema(map[string]any{"type": "string"}) + assert.Equal(t, "string", got.(map[string]any)["type"]) + + // 归一化生效:白名单外关键字剔除、缺 type 节点补全 + got = convertResponseSchema(map[string]any{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": map[string]any{ + "n": map[string]any{"type": "integer"}, + "x": map[string]any{"description": "补 type"}, }, }) - assert.Equal(t, "OBJECT", got.(map[string]any)["type"]) - - // 裸 schema(无包装) - got = convertResponseSchema(map[string]any{"type": "string"}) - assert.Equal(t, "STRING", got.(map[string]any)["type"]) - - // 非 map 原样返回 - assert.Equal(t, "raw", convertResponseSchema("raw")) + m = got.(map[string]any) + assert.NotContains(t, m, "$schema") + props = m["properties"].(map[string]any) + assert.Equal(t, "string", props["x"].(map[string]any)["type"]) } diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request.go new file mode 100644 index 0000000..5faa417 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request.go @@ -0,0 +1,314 @@ +package oai_gemini + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// GeminiToOpenAIRequestConverter 将 Gemini Generate Content 请求转换为 OpenAI Chat Completions 请求。 +type GeminiToOpenAIRequestConverter struct{} + +func (c *GeminiToOpenAIRequestConverter) ID() string { + return relayconvert.ConverterGeminiContentToOpenAIChat +} + +func (c *GeminiToOpenAIRequestConverter) From() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *GeminiToOpenAIRequestConverter) To() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *GeminiToOpenAIRequestConverter) Quality() relayconvert.RequestConverterQuality { + return relayconvert.RequestConverterQualityGood +} + +// ConvertRequest 将 Gemini 格式请求转换为 OpenAI Chat Completions 格式。 +func (c *GeminiToOpenAIRequestConverter) ConvertRequest( + ctx context.Context, + info convmeta.Meta, + request any, +) (any, error) { + geminiReq, ok := request.(*dto.GeminiChatRequest) + if !ok { + return nil, fmt.Errorf("expected *dto.GeminiChatRequest, got %T", request) + } + + model := "" + if info != nil { + model = info.GetUpstreamModelName() + } + + openaiReq := &dto.GeneralOpenAIRequest{ + Model: model, + Messages: make([]dto.Message, 0), + } + + if geminiReq.GenerationConfig != nil { + gc := geminiReq.GenerationConfig + openaiReq.Temperature = gc.Temperature + openaiReq.TopP = gc.TopP + if gc.TopK != nil { + v := int(*gc.TopK) + openaiReq.TopK = &v + } + if gc.MaxOutputTokens != nil { + v := int(*gc.MaxOutputTokens) + openaiReq.MaxTokens = &v + } + if len(gc.StopSequences) > 0 { + openaiReq.Stop = gc.StopSequences + } + if gc.Seed != nil { + v := *gc.Seed + openaiReq.Seed = &v + } + if gc.ThinkingConfig != nil { + openaiReq.ReasoningEffort = g2oConvertThinkingConfig(gc.ThinkingConfig) + } + if gc.ResponseMimeType != "" { + openaiReq.ResponseFormat = &dto.ResponseFormat{Type: "json_object"} + if gc.ResponseSchema != nil { + openaiReq.ResponseFormat.Type = "json_schema" + openaiReq.ResponseFormat.JSONSchema = gc.ResponseSchema + } + } + } + + if len(geminiReq.Tools) > 0 { + openaiReq.Tools = g2oConvertTools(geminiReq.Tools) + + // 服务端联网搜索:Gemini 的 googleSearch → chat 的 web_search_options。 + // chat 是跨原生方向的转换中枢(Gemini→OpenAI→Claude 两跳链), + // 这里不承载就等于整条链上的搜索能力全部丢失。 + if spec := shared.DetectWebSearchFromGeminiTools(geminiReq.Tools); spec != nil { + openaiReq.WebSearchOptions = spec.ToOpenAIOptions() + } + } + if geminiReq.ToolConfig != nil { + openaiReq.ToolChoice = g2oConvertToolConfig(geminiReq.ToolConfig) + } + + if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 { + var textParts []string + for _, p := range geminiReq.SystemInstruction.Parts { + if p.Text != "" { + textParts = append(textParts, p.Text) + } + } + if len(textParts) > 0 { + openaiReq.Messages = append(openaiReq.Messages, dto.Message{Role: "system", Content: strings.Join(textParts, "\n")}) + } + } + + toolCallIDCounter := 0 + toolCallIDs := make(map[string]string) + var pendingToolResults []dto.Message + + for _, content := range geminiReq.Contents { + msgs := g2oConvertContent(content, &toolCallIDCounter, toolCallIDs) + for i := range msgs { + if msgs[i].Role == "tool" { + pendingToolResults = append(pendingToolResults, msgs[i]) + } else { + if len(pendingToolResults) > 0 { + openaiReq.Messages = append(openaiReq.Messages, pendingToolResults...) + pendingToolResults = nil + } + openaiReq.Messages = append(openaiReq.Messages, msgs[i]) + } + } + } + if len(pendingToolResults) > 0 { + openaiReq.Messages = append(openaiReq.Messages, pendingToolResults...) + } + + return openaiReq, nil +} + +// g2oConvertContent 将单条 Gemini Content 转换为一或多条 OpenAI Message。 +// 函数响应(functionResponse)拆为独立的 tool 消息返回。 +func g2oConvertContent(content dto.GeminiContent, toolCallIDCounter *int, toolCallIDs map[string]string) []dto.Message { + role := g2oMapRole(content.Role) + var results []dto.Message + var textParts []string + var imageParts []dto.ContentPart + var funcCalls []dto.ToolCall + var funcResponses []dto.Message + + for _, part := range content.Parts { + switch { + case part.Text != "": + textParts = append(textParts, part.Text) + case part.InlineData != nil: + imageParts = append(imageParts, g2oConvertInlineData(part.InlineData)) + case part.FunctionCall != nil: + id := fmt.Sprintf("call_%d", *toolCallIDCounter) + *toolCallIDCounter++ + toolCallIDs[part.FunctionCall.FunctionName] = id + argsJSON := "{}" + if part.FunctionCall.Arguments != nil { + if b, err := json.Marshal(part.FunctionCall.Arguments); err == nil { + argsJSON = string(b) + } + } + funcCalls = append(funcCalls, dto.ToolCall{ID: id, Type: "function", Function: dto.FunctionCall{Name: part.FunctionCall.FunctionName, Arguments: argsJSON}}) + case part.FunctionResponse != nil: + name := part.FunctionResponse.Name + callID, ok := toolCallIDs[name] + if !ok { + callID = fmt.Sprintf("call_%d", *toolCallIDCounter) + *toolCallIDCounter++ + toolCallIDs[name] = callID + } + respJSON := "" + if part.FunctionResponse.Response != nil { + if b, err := json.Marshal(part.FunctionResponse.Response); err == nil { + respJSON = string(b) + } + } + funcResponses = append(funcResponses, dto.Message{Role: "tool", Content: respJSON, ToolCallID: callID}) + } + } + + switch role { + case "user": + if len(textParts) == 1 && len(imageParts) == 0 && len(funcResponses) == 0 { + results = append(results, dto.Message{Role: "user", Content: textParts[0]}) + } else if len(imageParts) > 0 { + var parts []dto.ContentPart + for _, t := range textParts { + parts = append(parts, dto.ContentPart{Type: "text", Text: t}) + } + parts = append(parts, imageParts...) + results = append(results, dto.Message{Role: "user", Content: parts}) + } else if len(textParts) > 0 { + results = append(results, dto.Message{Role: "user", Content: strings.Join(textParts, "\n")}) + } + case "assistant": + msg := dto.Message{Role: "assistant"} + if len(textParts) > 0 { + msg.Content = strings.Join(textParts, "\n") + } + if len(imageParts) > 0 { + msg.Content = imageParts + } + if len(funcCalls) > 0 { + msg.ToolCalls = funcCalls + if msg.Content == nil { + msg.Content = "" + } + } + results = append(results, msg) + } + results = append(results, funcResponses...) + return results +} + +// g2oMapRole 将 Gemini 角色映射为 OpenAI 角色。 +func g2oMapRole(role string) string { + switch role { + case "model": + return "assistant" + default: + return role + } +} + +// g2oConvertInlineData 将 Gemini 内联数据转换为 OpenAI image_url 内容块。 +func g2oConvertInlineData(data *dto.GeminiInlineData) dto.ContentPart { + return dto.ContentPart{ + Type: "image_url", + ImageURL: &dto.ImageURL{ + URL: fmt.Sprintf("data:%s;base64,%s", data.MimeType, data.Data), + Detail: "auto", + }, + } +} + +// g2oConvertTools 将 Gemini tools(functionDeclarations)转换为 OpenAI tools。 +func g2oConvertTools(toolsJSON json.RawMessage) []dto.Tool { + var geminiTools []struct { + FunctionDeclarations []struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` + } `json:"functionDeclarations"` + } + if err := json.Unmarshal(toolsJSON, &geminiTools); err != nil { + return nil + } + var result []dto.Tool + for _, gt := range geminiTools { + for _, fd := range gt.FunctionDeclarations { + result = append(result, dto.Tool{ + Type: "function", + Function: dto.FunctionDef{ + Name: fd.Name, Description: fd.Description, Parameters: fd.Parameters, + }, + }) + } + } + return result +} + +// g2oConvertToolConfig 将 Gemini toolConfig 转换为 OpenAI tool_choice。 +func g2oConvertToolConfig(toolConfig any) any { + tcMap, ok := toolConfig.(map[string]any) + if !ok { + return nil + } + fcc, ok := tcMap["functionCallingConfig"].(map[string]any) + if !ok { + return nil + } + mode, _ := fcc["mode"].(string) + switch mode { + case "NONE": + return "none" + case "AUTO": + return "auto" + case "ANY": + if names, ok := fcc["allowedFunctionNames"].([]any); ok && len(names) == 1 { + if name, ok := names[0].(string); ok { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + return "required" + default: + return "auto" + } +} + +// g2oConvertThinkingConfig 将 Gemini thinkingConfig 按预算档位映射为 OpenAI reasoning_effort。 +// 未携带 thinkingBudget 时回退读 thinkingLevel(Gemini 3 客户端的表达)。 +func g2oConvertThinkingConfig(tc *dto.GeminiThinkingConfig) string { + if tc.ThinkingBudget == nil { + // Gemini 3 客户端可能只带 thinkingLevel,按档位回退 + switch strings.ToLower(tc.ThinkingLevel) { + case "low": + return "low" + case "high": + return "high" + } + return "medium" + } + budget := *tc.ThinkingBudget + switch { + case budget <= 2048: + return "low" + case budget <= 16384: + return "medium" + default: + return "high" + } +} diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request_test.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request_test.go new file mode 100644 index 0000000..902b8e9 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_request_test.go @@ -0,0 +1,356 @@ +package oai_gemini + +import ( + "context" + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +func TestGeminiToOpenAIRequestConverter_Metadata(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + + if converter.ID() != relayconvert.ConverterGeminiContentToOpenAIChat { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ConverterGeminiContentToOpenAIChat) + } + + if converter.From() != types.RelayFormatGemini { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatGemini) + } + + if converter.To() != types.RelayFormatOpenAI { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatOpenAI) + } + + if converter.Quality() != relayconvert.RequestConverterQualityGood { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.RequestConverterQualityGood) + } +} + +func TestGeminiToOpenAIRequestConverter_BasicConversion(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + info := &convmeta.Values{UpstreamModelName: "gpt-4o", ChannelMetaAttached: true} + + temp := 0.7 + topP := 0.9 + topK := 40.0 + maxTokens := uint(1024) + seed := int64(42) + + geminiReq := &dto.GeminiChatRequest{ + SystemInstruction: &dto.GeminiContent{ + Parts: []dto.GeminiPart{{Text: "You are helpful."}, {Text: "Be brief."}}, + }, + Contents: []dto.GeminiContent{ + {Role: "user", Parts: []dto.GeminiPart{{Text: "Hello"}}}, + }, + GenerationConfig: &dto.GeminiGenerationConfig{ + Temperature: &temp, + TopP: &topP, + TopK: &topK, + MaxOutputTokens: &maxTokens, + StopSequences: []string{"END"}, + Seed: &seed, + }, + } + + result, err := converter.ConvertRequest(ctx, info, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + + openaiReq, ok := result.(*dto.GeneralOpenAIRequest) + if !ok { + t.Fatalf("Expected *dto.GeneralOpenAIRequest, got %T", result) + } + + if openaiReq.Model != "gpt-4o" { + t.Errorf("Model = %q, want %q", openaiReq.Model, "gpt-4o") + } + if openaiReq.Temperature == nil || *openaiReq.Temperature != 0.7 { + t.Errorf("Temperature = %v, want 0.7", openaiReq.Temperature) + } + if openaiReq.TopP == nil || *openaiReq.TopP != 0.9 { + t.Errorf("TopP = %v, want 0.9", openaiReq.TopP) + } + if openaiReq.TopK == nil || *openaiReq.TopK != 40 { + t.Errorf("TopK = %v, want 40", openaiReq.TopK) + } + if openaiReq.MaxTokens == nil || *openaiReq.MaxTokens != 1024 { + t.Errorf("MaxTokens = %v, want 1024", openaiReq.MaxTokens) + } + if openaiReq.Seed == nil || *openaiReq.Seed != 42 { + t.Errorf("Seed = %v, want 42", openaiReq.Seed) + } + stops, ok := openaiReq.Stop.([]string) + if !ok || len(stops) != 1 || stops[0] != "END" { + t.Errorf("Stop = %v, want [END]", openaiReq.Stop) + } + + // system 指令多段用换行拼接 + user 消息 + if len(openaiReq.Messages) != 2 { + t.Fatalf("Messages count = %d, want 2", len(openaiReq.Messages)) + } + if openaiReq.Messages[0].Role != "system" { + t.Errorf("First message role = %q, want system", openaiReq.Messages[0].Role) + } + if openaiReq.Messages[0].Content != "You are helpful.\nBe brief." { + t.Errorf("System content = %q", openaiReq.Messages[0].Content) + } + if openaiReq.Messages[1].Role != "user" || openaiReq.Messages[1].Content != "Hello" { + t.Errorf("User message = %+v", openaiReq.Messages[1]) + } +} + +func TestGeminiToOpenAIRequestConverter_ThinkingConfig(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + budgetOf := func(v int) *int { return &v } + + tests := []struct { + name string + tc *dto.GeminiThinkingConfig + effort string + }{ + {"无预算默认 medium", &dto.GeminiThinkingConfig{}, "medium"}, + {"无预算按 thinkingLevel 回退", &dto.GeminiThinkingConfig{ThinkingLevel: "low"}, "low"}, + {"无预算 thinkingLevel 大写兼容", &dto.GeminiThinkingConfig{ThinkingLevel: "HIGH"}, "high"}, + {"低预算", &dto.GeminiThinkingConfig{ThinkingBudget: budgetOf(1000)}, "low"}, + {"低预算边界", &dto.GeminiThinkingConfig{ThinkingBudget: budgetOf(2048)}, "low"}, + {"中预算", &dto.GeminiThinkingConfig{ThinkingBudget: budgetOf(8192)}, "medium"}, + {"中预算边界", &dto.GeminiThinkingConfig{ThinkingBudget: budgetOf(16384)}, "medium"}, + {"高预算", &dto.GeminiThinkingConfig{ThinkingBudget: budgetOf(32768)}, "high"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + geminiReq := &dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "hi"}}}}, + GenerationConfig: &dto.GeminiGenerationConfig{ThinkingConfig: tt.tc}, + } + result, err := converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + if openaiReq.ReasoningEffort != tt.effort { + t.Errorf("ReasoningEffort = %q, want %q", openaiReq.ReasoningEffort, tt.effort) + } + }) + } +} + +func TestGeminiToOpenAIRequestConverter_ResponseFormat(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + // 只有 mimeType → json_object + geminiReq := &dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "hi"}}}}, + GenerationConfig: &dto.GeminiGenerationConfig{ResponseMimeType: "application/json"}, + } + result, err := converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + if openaiReq.ResponseFormat == nil || openaiReq.ResponseFormat.Type != "json_object" { + t.Errorf("ResponseFormat = %+v, want json_object", openaiReq.ResponseFormat) + } + + // mimeType + schema → json_schema + schema := map[string]any{"type": "object"} + geminiReq.GenerationConfig.ResponseSchema = schema + result, err = converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq = result.(*dto.GeneralOpenAIRequest) + if openaiReq.ResponseFormat == nil || openaiReq.ResponseFormat.Type != "json_schema" { + t.Errorf("ResponseFormat = %+v, want json_schema", openaiReq.ResponseFormat) + } +} + +func TestGeminiToOpenAIRequestConverter_ToolsAndToolConfig(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + toolsJSON := json.RawMessage(`[{"functionDeclarations":[{"name":"get_weather","description":"查询天气","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]`) + + geminiReq := &dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "天气"}}}}, + Tools: toolsJSON, + ToolConfig: map[string]any{ + "functionCallingConfig": map[string]any{"mode": "ANY", "allowedFunctionNames": []any{"get_weather"}}, + }, + } + + result, err := converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + + if len(openaiReq.Tools) != 1 { + t.Fatalf("Tools count = %d, want 1", len(openaiReq.Tools)) + } + if openaiReq.Tools[0].Type != "function" || openaiReq.Tools[0].Function.Name != "get_weather" { + t.Errorf("Tool = %+v", openaiReq.Tools[0]) + } + if openaiReq.Tools[0].Function.Description != "查询天气" { + t.Errorf("Tool description = %q", openaiReq.Tools[0].Function.Description) + } + + // ANY + 单个函数名 → 强制指定函数 + tc, ok := openaiReq.ToolChoice.(map[string]any) + if !ok { + t.Fatalf("ToolChoice type = %T, want map", openaiReq.ToolChoice) + } + fn, _ := tc["function"].(map[string]any) + if tc["type"] != "function" || fn["name"] != "get_weather" { + t.Errorf("ToolChoice = %v", openaiReq.ToolChoice) + } +} + +func TestG2oConvertToolConfigModes(t *testing.T) { + tests := []struct { + name string + in any + want any + }{ + {"NONE", map[string]any{"functionCallingConfig": map[string]any{"mode": "NONE"}}, "none"}, + {"AUTO", map[string]any{"functionCallingConfig": map[string]any{"mode": "AUTO"}}, "auto"}, + {"ANY 无函数名", map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY"}}, "required"}, + {"未知 mode", map[string]any{"functionCallingConfig": map[string]any{"mode": "XXX"}}, "auto"}, + {"非 map 输入", "bad", nil}, + {"缺少 functionCallingConfig", map[string]any{}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := g2oConvertToolConfig(tt.in) + if got != tt.want { + t.Errorf("g2oConvertToolConfig(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestGeminiToOpenAIRequestConverter_FunctionCallHistory(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + geminiReq := &dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{ + {Role: "user", Parts: []dto.GeminiPart{{Text: "查天气"}}}, + {Role: "model", Parts: []dto.GeminiPart{ + {FunctionCall: &dto.GeminiFunctionCall{FunctionName: "get_weather", Arguments: map[string]any{"city": "北京"}}}, + }}, + {Role: "user", Parts: []dto.GeminiPart{ + {FunctionResponse: &dto.GeminiFunctionResponse{Name: "get_weather", Response: map[string]any{"temp": 25}}}, + }}, + }, + } + + result, err := converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + + // user + assistant(tool_calls) + tool + if len(openaiReq.Messages) != 3 { + t.Fatalf("Messages count = %d, want 3", len(openaiReq.Messages)) + } + + asst := openaiReq.Messages[1] + if asst.Role != "assistant" || len(asst.ToolCalls) != 1 { + t.Fatalf("Assistant message = %+v", asst) + } + if asst.ToolCalls[0].Function.Name != "get_weather" { + t.Errorf("ToolCall name = %q", asst.ToolCalls[0].Function.Name) + } + // 带 tool_calls 但无文本时 Content 需为空字符串而非 nil + if asst.Content != "" { + t.Errorf("Assistant content = %v, want empty string", asst.Content) + } + + toolMsg := openaiReq.Messages[2] + if toolMsg.Role != "tool" { + t.Fatalf("Third message role = %q, want tool", toolMsg.Role) + } + // functionResponse 复用 functionCall 分配的 ID + if toolMsg.ToolCallID != asst.ToolCalls[0].ID { + t.Errorf("ToolCallID = %q, want %q", toolMsg.ToolCallID, asst.ToolCalls[0].ID) + } + if toolMsg.Content != `{"temp":25}` { + t.Errorf("Tool content = %v", toolMsg.Content) + } +} + +func TestGeminiToOpenAIRequestConverter_InlineData(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + geminiReq := &dto.GeminiChatRequest{ + Contents: []dto.GeminiContent{ + {Role: "user", Parts: []dto.GeminiPart{ + {Text: "这是什么图?"}, + {InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "aGVsbG8="}}, + }}, + }, + } + + result, err := converter.ConvertRequest(ctx, nil, geminiReq) + if err != nil { + t.Fatalf("ConvertRequest failed: %v", err) + } + openaiReq := result.(*dto.GeneralOpenAIRequest) + + if len(openaiReq.Messages) != 1 { + t.Fatalf("Messages count = %d, want 1", len(openaiReq.Messages)) + } + parts, ok := openaiReq.Messages[0].Content.([]dto.ContentPart) + if !ok { + t.Fatalf("Content type = %T, want []dto.ContentPart", openaiReq.Messages[0].Content) + } + if len(parts) != 2 { + t.Fatalf("Content parts = %d, want 2", len(parts)) + } + if parts[0].Type != "text" || parts[0].Text != "这是什么图?" { + t.Errorf("Text part = %+v", parts[0]) + } + if parts[1].Type != "image_url" || parts[1].ImageURL == nil { + t.Fatalf("Image part = %+v", parts[1]) + } + if parts[1].ImageURL.URL != "data:image/png;base64,aGVsbG8=" { + t.Errorf("Image URL = %q", parts[1].ImageURL.URL) + } + if parts[1].ImageURL.Detail != "auto" { + t.Errorf("Image detail = %q, want auto", parts[1].ImageURL.Detail) + } +} + +func TestG2oMapRoleMapping(t *testing.T) { + if g2oMapRole("model") != "assistant" { + t.Errorf("g2oMapRole(model) = %q, want assistant", g2oMapRole("model")) + } + if g2oMapRole("user") != "user" { + t.Errorf("g2oMapRole(user) = %q, want user", g2oMapRole("user")) + } +} + +func TestGeminiToOpenAIRequestConverter_InvalidRequestType(t *testing.T) { + converter := &GeminiToOpenAIRequestConverter{} + ctx := context.Background() + + if _, err := converter.ConvertRequest(ctx, nil, "not a request"); err == nil { + t.Error("Expected error for invalid request type, got nil") + } +} diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go index 372c0c6..0d79080 100644 --- a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_response.go @@ -9,6 +9,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" "github.com/qianfree/team-api/relaykit/types" ) @@ -62,7 +63,7 @@ func (c *GeminiToOpenAIResponseConverter) ConvertResponse( // 检查 prompt feedback 中的安全拦截 if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { - return nil, fmt.Errorf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason) + return nil, fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) } // 将 candidates 转换为 choices @@ -138,6 +139,13 @@ func (c *GeminiToOpenAIResponseConverter) ConvertResponse( Content: strings.Join(textParts, ""), } + // 服务端搜索证据 → chat 的 url_citation annotations:上游真的去搜了, + // 来源不还原则客户端只拿到一段被搜索增强的纯文本,看不到来源、无法核查 + if citations := shared.ParseGeminiGrounding(candidate.GroundingMetadata); !citations.IsEmpty() { + citations.AlignSupports(strings.Join(textParts, "")) + message.Annotations = citations.ToOpenAIAnnotations() + } + // 添加 thinking 内容 if len(thinkingParts) > 0 { joined := strings.Join(thinkingParts, "\n") diff --git a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go index 7571bf3..9a62824 100644 --- a/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go +++ b/relaykit/relayconvert/internal/oai_gemini/gemini_to_openai_stream.go @@ -11,6 +11,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" "github.com/qianfree/team-api/relaykit/types" ) @@ -57,6 +58,8 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( finishReason string toolCallIdx int roleChunkSent bool + citations *shared.GroundingCitations + answerText strings.Builder ) newChunk := func(delta dto.Message) *dto.ChatCompletionStreamResponse { @@ -111,7 +114,7 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( // 检查 prompt feedback 中的安全拦截 if geminiResp.PromptFeedback != nil && geminiResp.PromptFeedback.BlockReason != "" { - return fmt.Errorf("request blocked by Gemini safety filter: %s", geminiResp.PromptFeedback.BlockReason) + return fmt.Errorf("request blocked by Gemini safety filter: %s: %w", geminiResp.PromptFeedback.BlockReason, relayconvert.ErrContentBlocked) } // 收集模型名 @@ -135,6 +138,10 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( if candidate.FinishReason != "" { finishReason = mapGeminiFinishReason(candidate.FinishReason) } + // grounding 随末帧(或靠后的帧)到达,先捕获、末帧统一附带引用 + if c := shared.ParseGeminiGrounding(candidate.GroundingMetadata); !c.IsEmpty() { + citations = c + } if candidate.Content == nil { continue @@ -153,6 +160,7 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( return err } } else { + answerText.WriteString(part.Text) if err := chunkWriter(newChunk(dto.Message{ Content: part.Text, })); err != nil { @@ -239,6 +247,14 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( }}, } + // 服务端搜索证据 → 末帧 delta 的 annotations。 + // 只能挂在末帧:groundingMetadata 随末帧到达,此时正文增量早已推给客户端; + // 要按来源逐段插入就得缓冲整段正文,那会毁掉流式首字延迟。 + if !citations.IsEmpty() { + citations.AlignSupports(answerText.String()) + finalChunk.Choices[0].Delta.Annotations = citations.ToOpenAIAnnotations() + } + if totalUsage.PromptTokenCount > 0 || totalUsage.CandidatesTokenCount > 0 { // OpenAI 口径:prompt 含 cached(子集),completion 含 thoughts(子集)—— // Gemini 的 candidatesTokenCount 不含思考 token,须与 thoughtsTokenCount 合计, diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request.go index 0f4f8b4..62e122e 100644 --- a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request.go +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request.go @@ -8,6 +8,7 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" "github.com/qianfree/team-api/relaykit/types" ) @@ -151,6 +152,26 @@ func (c *OpenAIToGeminiRequestConverter) ConvertRequest( } } + // 服务端联网搜索:chat 的 web_search_options → Gemini 原生 googleSearch。 + // + // 与其他目标格式不同,本方向受渠道开关 WebSearchToGoogleSearch 控制: + // googleSearch 是**用 Google 的 grounding 替代客户端请求的搜索**,Google 按搜索 + // 次数另行计价,因此由运营者显式开启(与 Claude→Gemini 方向同一开关、同一理由)。 + // + // 保守策略同样对齐 Claude→Gemini:仅在无 functionDeclarations 时附加—— + // 部分 Gemini 模型代际不接受 googleSearch 与函数声明同请求,混用时宁可丢搜索 + // 也不让整个请求 400。 + if convmeta.OptionsOf(info).Gemini.WebSearchToGoogleSearch && len(geminiReq.Tools) == 0 { + if spec := shared.DetectWebSearchFromOpenAI(openaiReq.WebSearchOptions); spec != nil { + entries := []map[string]any{shared.GeminiGoogleSearchEntry()} + raw, err := json.Marshal(entries) + if err != nil { + return nil, fmt.Errorf("marshal googleSearch tool: %w", err) + } + geminiReq.Tools = raw + } + } + // Messages conversion toolCallIDs := make(map[string]string) // toolCallID -> functionName var systemParts []dto.GeminiPart @@ -193,14 +214,9 @@ func (c *OpenAIToGeminiRequestConverter) ConvertRequest( name = toolCallIDs[msg.ToolCallID] } - contentStr := extractText(msg.Content) - var response any = contentStr - if contentStr != "" { - var parsed any - if json.Unmarshal([]byte(contentStr), &parsed) == nil { - response = parsed - } - } + // 工具输出归一化:functionResponse.response 必须是 JSON 对象(Struct), + // 纯文本(shell 输出等)会被 protojson 直接 400,统一经 shared 包包装 + response := shared.ToolResultToGeminiResponse(extractText(msg.Content)) geminiReq.Contents[lastIdx].Parts = append(geminiReq.Contents[lastIdx].Parts, dto.GeminiPart{ FunctionResponse: &dto.GeminiFunctionResponse{ @@ -276,6 +292,19 @@ func convertUserParts(content any) []dto.GeminiPart { } return []dto.GeminiPart{{Text: v}} + case []dto.ContentPart: + // 链式转换(如 Responses→OpenAI→Gemini)产出的类型化部件列表: + // 经 JSON 往返降为 []any 复用下方 wire 形态逻辑,避免两套多模态分支漂移 + data, err := json.Marshal(v) + if err != nil { + return nil + } + var anyParts []any + if err := json.Unmarshal(data, &anyParts); err != nil { + return nil + } + return convertUserParts(anyParts) + case []any: var parts []dto.GeminiPart for _, item := range v { @@ -400,7 +429,7 @@ func convertTools(tools []dto.Tool) ([]geminiTool, error) { if t.Type != "function" { continue } - cleanedParams := cleanParams(t.Function.Parameters) + cleanedParams := shared.CleanGeminiToolParams(t.Function.Parameters) funcDecls = append(funcDecls, functionDecl{ Name: t.Function.Name, Description: t.Function.Description, @@ -413,28 +442,6 @@ func convertTools(tools []dto.Tool) ([]geminiTool, error) { return []geminiTool{{FunctionDeclarations: funcDecls}}, nil } -// cleanParams 移除 Gemini 不支持的 JSON Schema 字段 -func cleanParams(params any) any { - if params == nil { - return nil - } - m, ok := params.(map[string]any) - if !ok { - return params - } - cleaned := make(map[string]any) - for k, v := range m { - switch k { - case "type", "description", "properties", "required", "items", - "anyOf", "default", "enum", "format", "maxLength", "minLength", - "maximum", "minimum", "pattern", "title", "nullable", - "maxItems", "minItems", "maxProperties", "minProperties", "example": - cleaned[k] = v - } - } - return cleaned -} - func convertToolChoice(toolChoice any) any { if toolChoice == nil { return nil @@ -467,111 +474,40 @@ func convertToolChoice(toolChoice any) any { return nil } +// convertReasoningEffort 将 reasoning_effort 档位展开为 Gemini thinkingBudget。 +// 只下发 thinkingBudget、不下发 thinkingLevel:二者互斥(同时携带上游返回 400), +// 且 Gemini 3 对 thinkingBudget 向后兼容。 func convertReasoningEffort(effort string) *dto.GeminiThinkingConfig { var budget int - var level string switch effort { case "low": budget = 1024 - level = "LOW" case "medium": budget = 8192 - level = "MEDIUM" case "high": budget = 32768 - level = "HIGH" default: budget = 8192 - level = "MEDIUM" } return &dto.GeminiThinkingConfig{ IncludeThoughts: true, - ThoughtBudget: &budget, - ThinkingLevel: level, + ThinkingBudget: &budget, } } +// convertResponseSchema 把 chat 的 response_format.json_schema 包装({name,schema,strict}) +// 解包为 Gemini 的 response_schema——必须是**裸 schema**,带着 name/strict 外壳发出会被 +// protojson 拒绝(线上实测:Unknown name "name" at 'request.generation_config.response_schema')。 +// 解包后经 CleanGeminiToolParams 归一化:Gemini 对 response_schema 与工具参数 schema +// 应用同一套约束(白名单关键字、每节点须有 type、数组须带 items)。 func convertResponseSchema(schema any) any { if schema == nil { return nil } - - // 处理 json_schema 包装结构:{"type":"json_schema","json_schema":{"schema":{...}}} if m, ok := schema.(map[string]any); ok { - if js, ok := m["json_schema"].(map[string]any); ok { - if innerSchema, ok := js["schema"]; ok { - return convertSchemaMap(innerSchema) - } - } - return convertSchemaMap(m) - } - - return schema -} - -// convertSchemaMap 递归地将 JSON Schema 类型名转换为 Gemini 格式 -func convertSchemaMap(schema any) any { - m, ok := schema.(map[string]any) - if !ok { - return schema - } - - result := make(map[string]any, len(m)) - for k, v := range m { - switch k { - case "type": - if s, ok := v.(string); ok { - result["type"] = mapSchemaType(s) - } else { - result[k] = v - } - case "properties": - if props, ok := v.(map[string]any); ok { - converted := make(map[string]any, len(props)) - for pk, pv := range props { - converted[pk] = convertSchemaMap(pv) - } - result["properties"] = converted - } else { - result[k] = v - } - case "items": - result["items"] = convertSchemaMap(v) - case "anyOf", "oneOf", "allOf": - if arr, ok := v.([]any); ok { - converted := make([]any, len(arr)) - for i, item := range arr { - converted[i] = convertSchemaMap(item) - } - result[k] = converted - } else { - result[k] = v - } - default: - result[k] = v + if inner, ok := m["schema"]; ok { + schema = inner } } - return result -} - -// mapSchemaType 将 JSON Schema 类型名映射为 Gemini Schema 类型名 -func mapSchemaType(t string) string { - switch t { - case "string": - return "STRING" - case "number": - return "NUMBER" - case "integer": - return "INTEGER" - case "boolean": - return "BOOLEAN" - case "object": - return "OBJECT" - case "array": - return "ARRAY" - case "null": - return "NULL" - default: - return t - } + return shared.CleanGeminiToolParams(schema) } diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request_test.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request_test.go index b36c119..a6dce48 100644 --- a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request_test.go +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_request_test.go @@ -356,11 +356,11 @@ func TestOpenAIToGeminiRequestConverter_ReasoningEffort(t *testing.T) { if !thinkingConfig.IncludeThoughts { t.Error("IncludeThoughts should be true") } - if thinkingConfig.ThoughtBudget == nil || *thinkingConfig.ThoughtBudget != tt.want.budget { - t.Errorf("ThoughtBudget = %v, want %d", thinkingConfig.ThoughtBudget, tt.want.budget) + if thinkingConfig.ThinkingBudget == nil || *thinkingConfig.ThinkingBudget != tt.want.budget { + t.Errorf("ThinkingBudget = %v, want %d", thinkingConfig.ThinkingBudget, tt.want.budget) } - if thinkingConfig.ThinkingLevel != tt.want.level { - t.Errorf("ThinkingLevel = %q, want %q", thinkingConfig.ThinkingLevel, tt.want.level) + if thinkingConfig.ThinkingLevel != "" { + t.Errorf("不应下发 thinkingLevel(与 thinkingBudget 互斥), got %q", thinkingConfig.ThinkingLevel) } }) } @@ -415,8 +415,8 @@ func TestOpenAIToGeminiRequestConverter_ResponseFormat(t *testing.T) { t.Fatalf("ResponseSchema is not map[string]any, got %T", geminiReq.GenerationConfig.ResponseSchema) } - if schemaMap["type"] != "OBJECT" { - t.Errorf("Schema type = %v, want %q", schemaMap["type"], "OBJECT") + if schemaMap["type"] != "object" { + t.Errorf("Schema type = %v, want %q", schemaMap["type"], "object") } props, ok := schemaMap["properties"].(map[string]any) @@ -428,16 +428,16 @@ func TestOpenAIToGeminiRequestConverter_ResponseFormat(t *testing.T) { if !ok { t.Fatal("name property not found") } - if nameSchema["type"] != "STRING" { - t.Errorf("name type = %v, want %q", nameSchema["type"], "STRING") + if nameSchema["type"] != "string" { + t.Errorf("name type = %v, want %q", nameSchema["type"], "string") } ageSchema, ok := props["age"].(map[string]any) if !ok { t.Fatal("age property not found") } - if ageSchema["type"] != "INTEGER" { - t.Errorf("age type = %v, want %q", ageSchema["type"], "INTEGER") + if ageSchema["type"] != "integer" { + t.Errorf("age type = %v, want %q", ageSchema["type"], "integer") } } @@ -667,3 +667,43 @@ func TestOpenAIToGeminiRequestConverter_InvalidRequestType(t *testing.T) { t.Fatal("Expected error for invalid request type, got nil") } } + +// TestOpenAIToGeminiRequestConverter_ToolOutputWrappedAsObject 工具输出必须是 +// JSON 对象(google.protobuf.Struct):纯文本(shell 输出等)包成 {"result": ...}, +// 否则 Gemini protojson 直接 400(线上实测 codex 的混合文本工具输出被拒)。 +func TestOpenAIToGeminiRequestConverter_ToolOutputWrappedAsObject(t *testing.T) { + converter := &OpenAIToGeminiRequestConverter{} + + run := func(toolContent string) map[string]any { + t.Helper() + out, err := converter.ConvertRequest(context.Background(), nil, &dto.GeneralOpenAIRequest{ + Messages: []dto.Message{ + {Role: "assistant", ToolCalls: []dto.ToolCall{{ID: "call_1", Type: "function", Function: dto.FunctionCall{Name: "shell", Arguments: "{}"}}}}, + {Role: "tool", ToolCallID: "call_1", Content: toolContent}, + }, + }) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + req := out.(*dto.GeminiChatRequest) + last := req.Contents[len(req.Contents)-1] + fr := last.Parts[len(last.Parts)-1].FunctionResponse + return fr.Response.(map[string]any) + } + + // 混合文本(非 JSON):包装 + resp := run("Chunk ID: edb420\nOutput:\n{\"temp\": 31}") + if resp["result"] != "Chunk ID: edb420\nOutput:\n{\"temp\": 31}" { + t.Errorf("非 JSON 工具输出应包成 {result: ...},实际 %v", resp) + } + // JSON 对象:原样透传 + resp = run(`{"temp": 31}`) + if resp["temp"] != float64(31) { + t.Errorf("JSON 对象工具输出应原样透传,实际 %v", resp) + } + // JSON 数组:也必须包装(Struct 不接受顶层数组) + resp = run(`[1,2]`) + if _, ok := resp["result"]; !ok { + t.Errorf("顶层数组应包装为 {result: ...},实际 %v", resp) + } +} diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response.go new file mode 100644 index 0000000..692236a --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response.go @@ -0,0 +1,147 @@ +package oai_gemini + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToGeminiResponseConverter 将 OpenAI Chat Completions 响应转换为 Gemini Generate Content 响应。 +type OpenAIToGeminiResponseConverter struct{} + +func (c *OpenAIToGeminiResponseConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToGeminiChat +} + +func (c *OpenAIToGeminiResponseConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToGeminiResponseConverter) To() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *OpenAIToGeminiResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 OpenAI ChatCompletion 非流式响应转换为 Gemini Chat 响应。 +func (c *OpenAIToGeminiResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + openaiResp, ok := response.(*dto.ChatCompletionResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.ChatCompletionResponse, got %T", response) + } + + resp := &dto.GeminiChatResponse{} + + if len(openaiResp.Choices) == 0 { + return resp, nil + } + + choice := openaiResp.Choices[0] + parts := buildGeminiPartsFromMessage(&choice.Message) + + candidate := dto.GeminiCandidate{ + Index: choice.Index, + Content: &dto.GeminiContent{ + Role: "model", + Parts: parts, + }, + FinishReason: mapOpenAIFinishReasonToGemini(choice.FinishReason), + } + + resp.Candidates = []dto.GeminiCandidate{candidate} + + // Gemini 语义:CandidatesTokenCount 不含 thoughts,OpenAI CompletionTokens 已含 reasoning + // 需要扣减 reasoning 避免双计(Gemini 客户端按 total = prompt + candidates + thoughts 汇总) + candidatesTokens := openaiResp.Usage.CompletionTokens + if openaiResp.Usage.CompletionTokenDetails != nil && openaiResp.Usage.CompletionTokenDetails.ReasoningTokens > 0 { + candidatesTokens -= openaiResp.Usage.CompletionTokenDetails.ReasoningTokens + if candidatesTokens < 0 { + candidatesTokens = 0 + } + } + + resp.UsageMetadata = &dto.GeminiUsageMetadata{ + PromptTokenCount: openaiResp.Usage.PromptTokens, + CandidatesTokenCount: candidatesTokens, + TotalTokenCount: openaiResp.Usage.TotalTokens, + } + if openaiResp.Usage.PromptTokensDetails != nil { + resp.UsageMetadata.CachedContentTokenCount = openaiResp.Usage.PromptTokensDetails.CachedTokens + } + if openaiResp.Usage.CompletionTokenDetails != nil { + resp.UsageMetadata.ThoughtsTokenCount = openaiResp.Usage.CompletionTokenDetails.ReasoningTokens + } + + return resp, nil +} + +// buildGeminiPartsFromMessage 将 OpenAI Message 转换为 Gemini Parts +func buildGeminiPartsFromMessage(msg *dto.Message) []dto.GeminiPart { + var parts []dto.GeminiPart + + // thinking 内容 → thought part + if msg.ReasoningContent != nil && *msg.ReasoningContent != "" { + parts = append(parts, dto.GeminiPart{ + Text: *msg.ReasoningContent, + Thought: boolPtr(true), + }) + } + + // 文本内容 + if text, ok := msg.Content.(string); ok && text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + + // 工具调用 → functionCall parts + for _, tc := range msg.ToolCalls { + var args any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + ID: tc.ID, + FunctionName: tc.Function.Name, + Arguments: args, + }, + }) + } + + if len(parts) == 0 { + parts = append(parts, dto.GeminiPart{Text: ""}) + } + + return parts +} + +// mapOpenAIFinishReasonToGemini 将 OpenAI finish_reason 转换为 Gemini finishReason +func mapOpenAIFinishReasonToGemini(reason string) string { + switch reason { + case "stop": + return "STOP" + case "length": + return "MAX_TOKENS" + case "content_filter": + return "SAFETY" + case "tool_calls": + return "STOP" + default: + return reason + } +} + +// boolPtr 返回 bool 的指针 +func boolPtr(v bool) *bool { + return &v +} diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response_test.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response_test.go new file mode 100644 index 0000000..06e78b4 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_response_test.go @@ -0,0 +1,311 @@ +package oai_gemini + +import ( + "context" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/types" +) + +func TestOpenAIToGeminiResponseConverter_Metadata(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + + if converter.ID() != relayconvert.ResponseConverterOAIChatToGeminiChat { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ResponseConverterOAIChatToGeminiChat) + } + + if converter.From() != types.RelayFormatOpenAI { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatOpenAI) + } + + if converter.To() != types.RelayFormatGemini { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatGemini) + } + + if converter.Quality() != relayconvert.ResponseConverterQualityGood { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.ResponseConverterQualityGood) + } +} + +func TestOpenAIToGeminiResponseConverter_BasicConversion(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + openaiResp := &dto.ChatCompletionResponse{ + ID: "chatcmpl-123", + Model: "gpt-4o", + Choices: []dto.Choice{ + { + Index: 0, + FinishReason: "stop", + Message: dto.Message{ + Role: "assistant", + Content: "Hello there!", + }, + }, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: 10, + CompletionTokens: 20, + TotalTokens: 30, + }, + } + + result, err := converter.ConvertResponse(ctx, nil, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + + geminiResp, ok := result.(*dto.GeminiChatResponse) + if !ok { + t.Fatalf("Expected *dto.GeminiChatResponse, got %T", result) + } + + if len(geminiResp.Candidates) != 1 { + t.Fatalf("Candidates count = %d, want 1", len(geminiResp.Candidates)) + } + candidate := geminiResp.Candidates[0] + if candidate.FinishReason != "STOP" { + t.Errorf("FinishReason = %q, want STOP", candidate.FinishReason) + } + if candidate.Content == nil || candidate.Content.Role != "model" { + t.Fatalf("Candidate content = %+v", candidate.Content) + } + if len(candidate.Content.Parts) != 1 || candidate.Content.Parts[0].Text != "Hello there!" { + t.Errorf("Parts = %+v", candidate.Content.Parts) + } + + if geminiResp.UsageMetadata == nil { + t.Fatal("UsageMetadata missing") + } + if geminiResp.UsageMetadata.PromptTokenCount != 10 { + t.Errorf("PromptTokenCount = %d, want 10", geminiResp.UsageMetadata.PromptTokenCount) + } + if geminiResp.UsageMetadata.CandidatesTokenCount != 20 { + t.Errorf("CandidatesTokenCount = %d, want 20", geminiResp.UsageMetadata.CandidatesTokenCount) + } + if geminiResp.UsageMetadata.TotalTokenCount != 30 { + t.Errorf("TotalTokenCount = %d, want 30", geminiResp.UsageMetadata.TotalTokenCount) + } +} + +func TestOpenAIToGeminiResponseConverter_ReasoningContent(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + reasoning := "Let me think..." + openaiResp := &dto.ChatCompletionResponse{ + Choices: []dto.Choice{ + { + FinishReason: "stop", + Message: dto.Message{ + Role: "assistant", + Content: "The answer is 42.", + ReasoningContent: &reasoning, + }, + }, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: 10, + CompletionTokens: 50, + TotalTokens: 60, + CompletionTokenDetails: &dto.TokenDetails{ReasoningTokens: 30}, + }, + } + + result, err := converter.ConvertResponse(ctx, nil, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + geminiResp := result.(*dto.GeminiChatResponse) + + parts := geminiResp.Candidates[0].Content.Parts + if len(parts) != 2 { + t.Fatalf("Parts count = %d, want 2", len(parts)) + } + // thinking 内容排在前面且带 thought 标记 + if parts[0].Text != "Let me think..." || parts[0].Thought == nil || !*parts[0].Thought { + t.Errorf("Thought part = %+v", parts[0]) + } + if parts[1].Text != "The answer is 42." || parts[1].Thought != nil { + t.Errorf("Text part = %+v", parts[1]) + } + + // Gemini 口径:candidatesTokenCount 不含 reasoning(50 - 30 = 20) + if geminiResp.UsageMetadata.CandidatesTokenCount != 20 { + t.Errorf("CandidatesTokenCount = %d, want 20", geminiResp.UsageMetadata.CandidatesTokenCount) + } + if geminiResp.UsageMetadata.ThoughtsTokenCount != 30 { + t.Errorf("ThoughtsTokenCount = %d, want 30", geminiResp.UsageMetadata.ThoughtsTokenCount) + } + if geminiResp.UsageMetadata.TotalTokenCount != 60 { + t.Errorf("TotalTokenCount = %d, want 60", geminiResp.UsageMetadata.TotalTokenCount) + } +} + +func TestOpenAIToGeminiResponseConverter_ToolCalls(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + openaiResp := &dto.ChatCompletionResponse{ + Choices: []dto.Choice{ + { + FinishReason: "tool_calls", + Message: dto.Message{ + Role: "assistant", + ToolCalls: []dto.ToolCall{ + { + ID: "call_abc", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_weather", + Arguments: `{"city":"北京"}`, + }, + }, + { + ID: "call_def", + Type: "function", + Function: dto.FunctionCall{ + Name: "get_time", + Arguments: `invalid json`, + }, + }, + }, + }, + }, + }, + } + + result, err := converter.ConvertResponse(ctx, nil, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + geminiResp := result.(*dto.GeminiChatResponse) + + candidate := geminiResp.Candidates[0] + // tool_calls → STOP(Gemini 无独立工具调用结束原因) + if candidate.FinishReason != "STOP" { + t.Errorf("FinishReason = %q, want STOP", candidate.FinishReason) + } + + parts := candidate.Content.Parts + if len(parts) != 2 { + t.Fatalf("Parts count = %d, want 2", len(parts)) + } + fc := parts[0].FunctionCall + if fc == nil || fc.ID != "call_abc" || fc.FunctionName != "get_weather" { + t.Fatalf("FunctionCall part = %+v", parts[0]) + } + args, ok := fc.Arguments.(map[string]any) + if !ok || args["city"] != "北京" { + t.Errorf("Arguments = %v", fc.Arguments) + } + // 非法 JSON 参数降级为空 map + fc2 := parts[1].FunctionCall + if fc2 == nil { + t.Fatal("Second FunctionCall missing") + } + args2, ok := fc2.Arguments.(map[string]any) + if !ok || len(args2) != 0 { + t.Errorf("Invalid-JSON arguments = %v, want empty map", fc2.Arguments) + } +} + +func TestOpenAIToGeminiResponseConverter_EmptyChoices(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + result, err := converter.ConvertResponse(ctx, nil, &dto.ChatCompletionResponse{}) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + geminiResp := result.(*dto.GeminiChatResponse) + if len(geminiResp.Candidates) != 0 { + t.Errorf("Candidates = %+v, want empty", geminiResp.Candidates) + } + // 空 choices 时不写 usage + if geminiResp.UsageMetadata != nil { + t.Errorf("UsageMetadata = %+v, want nil", geminiResp.UsageMetadata) + } +} + +func TestOpenAIToGeminiResponseConverter_EmptyMessageFallbackPart(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + openaiResp := &dto.ChatCompletionResponse{ + Choices: []dto.Choice{ + {FinishReason: "stop", Message: dto.Message{Role: "assistant"}}, + }, + } + + result, err := converter.ConvertResponse(ctx, nil, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + geminiResp := result.(*dto.GeminiChatResponse) + + // 空消息兜底一个空 text part + parts := geminiResp.Candidates[0].Content.Parts + if len(parts) != 1 || parts[0].Text != "" || parts[0].FunctionCall != nil { + t.Errorf("Parts = %+v, want single empty text part", parts) + } +} + +func TestOpenAIToGeminiResponseConverter_CachedTokens(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + openaiResp := &dto.ChatCompletionResponse{ + Choices: []dto.Choice{ + {FinishReason: "stop", Message: dto.Message{Role: "assistant", Content: "hi"}}, + }, + Usage: dto.UsageWithDetails{ + PromptTokens: 100, + CompletionTokens: 10, + TotalTokens: 110, + PromptTokensDetails: &dto.TokenDetails{CachedTokens: 80}, + }, + } + + result, err := converter.ConvertResponse(ctx, nil, openaiResp) + if err != nil { + t.Fatalf("ConvertResponse failed: %v", err) + } + geminiResp := result.(*dto.GeminiChatResponse) + + if geminiResp.UsageMetadata.CachedContentTokenCount != 80 { + t.Errorf("CachedContentTokenCount = %d, want 80", geminiResp.UsageMetadata.CachedContentTokenCount) + } +} + +func TestMapOpenAIFinishReasonToGemini(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"stop", "STOP"}, + {"length", "MAX_TOKENS"}, + {"content_filter", "SAFETY"}, + {"tool_calls", "STOP"}, + {"", ""}, + {"custom_reason", "custom_reason"}, + } + + for _, tt := range tests { + if got := mapOpenAIFinishReasonToGemini(tt.in); got != tt.want { + t.Errorf("mapOpenAIFinishReasonToGemini(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestOpenAIToGeminiResponseConverter_InvalidResponseType(t *testing.T) { + converter := &OpenAIToGeminiResponseConverter{} + ctx := context.Background() + + if _, err := converter.ConvertResponse(ctx, nil, "not a response"); err == nil { + t.Error("Expected error for invalid response type, got nil") + } +} diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream.go new file mode 100644 index 0000000..abd35e0 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream.go @@ -0,0 +1,208 @@ +package oai_gemini + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToGeminiStreamConverter 将 OpenAI Chat Completions 流式响应转换为 Gemini 流式响应。 +type OpenAIToGeminiStreamConverter struct{} + +func (c *OpenAIToGeminiStreamConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToGeminiChatStream +} + +func (c *OpenAIToGeminiStreamConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToGeminiStreamConverter) To() types.RelayFormat { + return types.RelayFormatGemini +} + +func (c *OpenAIToGeminiStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将上游 OpenAI SSE 流转换为 Gemini 流式 GenerateContentResponse 帧。 +// Gemini 客户端为纯 data 帧风格,每帧以 StreamEvent{Event: ""} 输出; +// 最终帧携带 usageMetadata 时同时填充 StreamEvent.Usage 供宿主捕获计费用量。 +func (c *OpenAIToGeminiStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + var ( + usage dto.UsageWithDetails + finishReason string + ) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" { + continue + } + + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimPrefix(line, "data:") + data = strings.TrimSpace(data) + + if data == "" { + continue + } + if data == "[DONE]" { + break + } + + var streamResp dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + + // 收集 usage(通常在最后一个 chunk) + if streamResp.Usage != nil { + usage.PromptTokens = streamResp.Usage.PromptTokens + usage.CompletionTokens = streamResp.Usage.CompletionTokens + usage.TotalTokens = streamResp.Usage.TotalTokens + usage.PromptTokensDetails = streamResp.Usage.PromptTokensDetails + usage.CompletionTokenDetails = streamResp.Usage.CompletionTokenDetails + } + + // 构造 Gemini 响应 chunk + geminiChunk := dto.GeminiChatResponse{} + + for _, choice := range streamResp.Choices { + if choice.FinishReason != nil && *choice.FinishReason != "" { + finishReason = *choice.FinishReason + } + + parts := buildGeminiPartsFromDelta(&choice.Delta) + if len(parts) == 0 && choice.FinishReason == nil { + continue + } + + candidate := dto.GeminiCandidate{ + Index: choice.Index, + Content: &dto.GeminiContent{ + Role: "model", + Parts: parts, + }, + } + + geminiChunk.Candidates = append(geminiChunk.Candidates, candidate) + } + + if len(geminiChunk.Candidates) > 0 { + if err := chunkWriter(&relayconvert.StreamEvent{Data: &geminiChunk}); err != nil { + return err + } + } + } + + // 发送包含 finishReason 和 usageMetadata 的最终 chunk + finalChunk := dto.GeminiChatResponse{} + reason := mapOpenAIFinishReasonToGemini(finishReason) + finalChunk.Candidates = []dto.GeminiCandidate{{ + Content: &dto.GeminiContent{ + Role: "model", + }, + FinishReason: reason, + }} + if usage.PromptTokens > 0 || usage.CompletionTokens > 0 { + // Gemini 语义:CandidatesTokenCount 不含 thoughts,OpenAI CompletionTokens 已含 reasoning + // 需要扣减 reasoning 避免双计(Gemini 客户端按 total = prompt + candidates + thoughts 汇总) + candidatesTokens := usage.CompletionTokens + if usage.CompletionTokenDetails != nil && usage.CompletionTokenDetails.ReasoningTokens > 0 { + candidatesTokens -= usage.CompletionTokenDetails.ReasoningTokens + if candidatesTokens < 0 { + candidatesTokens = 0 + } + } + + finalChunk.UsageMetadata = &dto.GeminiUsageMetadata{ + PromptTokenCount: usage.PromptTokens, + CandidatesTokenCount: candidatesTokens, + TotalTokenCount: usage.TotalTokens, + } + if usage.PromptTokensDetails != nil { + finalChunk.UsageMetadata.CachedContentTokenCount = usage.PromptTokensDetails.CachedTokens + } + if usage.CompletionTokenDetails != nil { + finalChunk.UsageMetadata.ThoughtsTokenCount = usage.CompletionTokenDetails.ReasoningTokens + } + } + // 最终帧同时携带 usage(含 token 明细,缓存明细影响计费),供宿主捕获为本次请求的计费用量 + capturedUsage := usage + if err := chunkWriter(&relayconvert.StreamEvent{ + Data: &finalChunk, + Usage: &capturedUsage, + }); err != nil { + return err + } + + if err := scanner.Err(); err != nil && err != io.EOF && ctx.Err() == nil { + return fmt.Errorf("stream scanner error: %w", err) + } + + return nil +} + +// buildGeminiPartsFromDelta 将 OpenAI 流式 Delta 转换为 Gemini Parts +func buildGeminiPartsFromDelta(delta *dto.Message) []dto.GeminiPart { + var parts []dto.GeminiPart + + // thinking 内容 + if delta.ReasoningContent != nil && *delta.ReasoningContent != "" { + parts = append(parts, dto.GeminiPart{ + Text: *delta.ReasoningContent, + Thought: boolPtr(true), + }) + } + + // 文本内容 + if text, ok := delta.Content.(string); ok && text != "" { + parts = append(parts, dto.GeminiPart{Text: text}) + } + + // 工具调用 + for _, tc := range delta.ToolCalls { + var args any + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args = map[string]any{} + } + } + parts = append(parts, dto.GeminiPart{ + FunctionCall: &dto.GeminiFunctionCall{ + ID: tc.ID, + FunctionName: tc.Function.Name, + Arguments: args, + }, + }) + } + + return parts +} diff --git a/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream_test.go b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream_test.go new file mode 100644 index 0000000..2e9989e --- /dev/null +++ b/relaykit/relayconvert/internal/oai_gemini/openai_to_gemini_stream_test.go @@ -0,0 +1,308 @@ +package oai_gemini + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/types" +) + +func TestOpenAIToGeminiStreamConverter_Metadata(t *testing.T) { + converter := &OpenAIToGeminiStreamConverter{} + + if converter.ID() != relayconvert.ResponseConverterOAIChatToGeminiChatStream { + t.Errorf("ID() = %q, want %q", converter.ID(), relayconvert.ResponseConverterOAIChatToGeminiChatStream) + } + + if converter.From() != types.RelayFormatOpenAI { + t.Errorf("From() = %q, want %q", converter.From(), types.RelayFormatOpenAI) + } + + if converter.To() != types.RelayFormatGemini { + t.Errorf("To() = %q, want %q", converter.To(), types.RelayFormatGemini) + } + + if converter.Quality() != relayconvert.ResponseConverterQualityGood { + t.Errorf("Quality() = %q, want %q", converter.Quality(), relayconvert.ResponseConverterQualityGood) + } +} + +// collectGeminiStreamEvents 运行流式转换并收集全部 StreamEvent。 +func collectGeminiStreamEvents(t *testing.T, streamData string) []*relayconvert.StreamEvent { + t.Helper() + converter := &OpenAIToGeminiStreamConverter{} + + var events []*relayconvert.StreamEvent + chunkWriter := func(chunk any) error { + event, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("Expected *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, event) + return nil + } + + if err := converter.ConvertStreamResponse(context.Background(), nil, strings.NewReader(streamData), chunkWriter); err != nil { + t.Fatalf("ConvertStreamResponse failed: %v", err) + } + return events +} + +// geminiChunkOf 从 StreamEvent 中取出 Gemini chunk。 +func geminiChunkOf(t *testing.T, event *relayconvert.StreamEvent) *dto.GeminiChatResponse { + t.Helper() + chunk, ok := event.Data.(*dto.GeminiChatResponse) + if !ok { + t.Fatalf("Expected *dto.GeminiChatResponse, got %T", event.Data) + } + return chunk +} + +func TestOpenAIToGeminiStreamConverter_BasicStreaming(t *testing.T) { + streamData := `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]} + +data: {"id":"chatcmpl-1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":10,"total_tokens":15}} + +data: [DONE] + +` + + events := collectGeminiStreamEvents(t, streamData) + + // "Hello" + " world" + finish_reason 帧 + 最终帧 + if len(events) != 4 { + t.Fatalf("Event count = %d, want 4", len(events)) + } + + // Gemini 客户端为纯 data 帧风格,Event 必须为空 + for i, ev := range events { + if ev.Event != "" { + t.Errorf("Event[%d].Event = %q, want empty", i, ev.Event) + } + } + + chunk0 := geminiChunkOf(t, events[0]) + if len(chunk0.Candidates) != 1 { + t.Fatalf("First chunk candidates = %d, want 1", len(chunk0.Candidates)) + } + if chunk0.Candidates[0].Content.Role != "model" { + t.Errorf("First chunk role = %q, want model", chunk0.Candidates[0].Content.Role) + } + if chunk0.Candidates[0].Content.Parts[0].Text != "Hello" { + t.Errorf("First chunk text = %q, want Hello", chunk0.Candidates[0].Content.Parts[0].Text) + } + + chunk1 := geminiChunkOf(t, events[1]) + if chunk1.Candidates[0].Content.Parts[0].Text != " world" { + t.Errorf("Second chunk text = %q, want ' world'", chunk1.Candidates[0].Content.Parts[0].Text) + } + + // finish_reason 帧:parts 为空但 finish_reason 非 nil 也会输出候选(与旧实现一致) + chunk2 := geminiChunkOf(t, events[2]) + if len(chunk2.Candidates) != 1 || len(chunk2.Candidates[0].Content.Parts) != 0 { + t.Errorf("Third chunk = %+v", chunk2.Candidates) + } + + // 最终帧:finishReason + usageMetadata + finalChunk := geminiChunkOf(t, events[3]) + if len(finalChunk.Candidates) != 1 { + t.Fatalf("Final chunk candidates = %d, want 1", len(finalChunk.Candidates)) + } + if finalChunk.Candidates[0].FinishReason != "STOP" { + t.Errorf("Final finishReason = %q, want STOP", finalChunk.Candidates[0].FinishReason) + } + if finalChunk.UsageMetadata == nil { + t.Fatal("Final chunk missing usageMetadata") + } + if finalChunk.UsageMetadata.PromptTokenCount != 5 { + t.Errorf("PromptTokenCount = %d, want 5", finalChunk.UsageMetadata.PromptTokenCount) + } + if finalChunk.UsageMetadata.CandidatesTokenCount != 10 { + t.Errorf("CandidatesTokenCount = %d, want 10", finalChunk.UsageMetadata.CandidatesTokenCount) + } + if finalChunk.UsageMetadata.TotalTokenCount != 15 { + t.Errorf("TotalTokenCount = %d, want 15", finalChunk.UsageMetadata.TotalTokenCount) + } + + // 最终帧同时携带 StreamEvent.Usage 供宿主捕获 + if events[3].Usage == nil { + t.Fatal("Final event missing Usage") + } + if events[3].Usage.PromptTokens != 5 || events[3].Usage.CompletionTokens != 10 || events[3].Usage.TotalTokens != 15 { + t.Errorf("Final event usage = %+v", events[3].Usage) + } + // 中间帧不携带 Usage + for i := 0; i < 3; i++ { + if events[i].Usage != nil { + t.Errorf("Event[%d] has unexpected Usage", i) + } + } +} + +func TestOpenAIToGeminiStreamConverter_ReasoningStreaming(t *testing.T) { + streamData := `data: {"choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60,"completion_tokens_details":{"reasoning_tokens":30}}} + +data: [DONE] + +` + + events := collectGeminiStreamEvents(t, streamData) + if len(events) != 4 { + t.Fatalf("Event count = %d, want 4", len(events)) + } + + // reasoning_content → thought part + chunk0 := geminiChunkOf(t, events[0]) + part := chunk0.Candidates[0].Content.Parts[0] + if part.Text != "thinking..." || part.Thought == nil || !*part.Thought { + t.Errorf("Thought part = %+v", part) + } + + chunk1 := geminiChunkOf(t, events[1]) + if chunk1.Candidates[0].Content.Parts[0].Text != "Answer" { + t.Errorf("Text part = %+v", chunk1.Candidates[0].Content.Parts[0]) + } + + // 最终帧:candidatesTokenCount 扣减 reasoning(50 - 30 = 20),thoughtsTokenCount = 30 + finalChunk := geminiChunkOf(t, events[3]) + if finalChunk.UsageMetadata.CandidatesTokenCount != 20 { + t.Errorf("CandidatesTokenCount = %d, want 20", finalChunk.UsageMetadata.CandidatesTokenCount) + } + if finalChunk.UsageMetadata.ThoughtsTokenCount != 30 { + t.Errorf("ThoughtsTokenCount = %d, want 30", finalChunk.UsageMetadata.ThoughtsTokenCount) + } + // StreamEvent.Usage 保持 OpenAI 口径(completion 含 reasoning),并透传明细 + if events[3].Usage.CompletionTokens != 50 { + t.Errorf("Usage CompletionTokens = %d, want 50", events[3].Usage.CompletionTokens) + } + if events[3].Usage.CompletionTokenDetails == nil || events[3].Usage.CompletionTokenDetails.ReasoningTokens != 30 { + t.Errorf("Usage CompletionTokenDetails = %+v, want ReasoningTokens 30", events[3].Usage.CompletionTokenDetails) + } +} + +func TestOpenAIToGeminiStreamConverter_ToolCallStreaming(t *testing.T) { + streamData := `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] + +` + + events := collectGeminiStreamEvents(t, streamData) + if len(events) != 3 { + t.Fatalf("Event count = %d, want 3", len(events)) + } + + chunk0 := geminiChunkOf(t, events[0]) + fc := chunk0.Candidates[0].Content.Parts[0].FunctionCall + if fc == nil || fc.ID != "call_1" || fc.FunctionName != "get_weather" { + t.Fatalf("FunctionCall = %+v", fc) + } + args, ok := fc.Arguments.(map[string]any) + if !ok || args["city"] != "北京" { + t.Errorf("Arguments = %v", fc.Arguments) + } + + // tool_calls → STOP + finalChunk := geminiChunkOf(t, events[2]) + if finalChunk.Candidates[0].FinishReason != "STOP" { + t.Errorf("Final finishReason = %q, want STOP", finalChunk.Candidates[0].FinishReason) + } + // 无 usage 时最终帧不带 usageMetadata,但 StreamEvent.Usage 仍存在(零值) + if finalChunk.UsageMetadata != nil { + t.Errorf("UsageMetadata = %+v, want nil", finalChunk.UsageMetadata) + } + if events[2].Usage == nil || events[2].Usage.TotalTokens != 0 { + t.Errorf("Final event usage = %+v, want zero-value usage", events[2].Usage) + } +} + +func TestOpenAIToGeminiStreamConverter_CachedTokensAndMalformedLines(t *testing.T) { + // 含非法 JSON 行、非 data 行、空 data 行,均应被跳过 + streamData := `: ping + +event: something + +data: not-json + +data: + +data: {"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":null}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110,"prompt_tokens_details":{"cached_tokens":80}}} + +data: [DONE] + +` + + events := collectGeminiStreamEvents(t, streamData) + if len(events) != 3 { + t.Fatalf("Event count = %d, want 3", len(events)) + } + + chunk0 := geminiChunkOf(t, events[0]) + if chunk0.Candidates[0].Content.Parts[0].Text != "ok" { + t.Errorf("First chunk text = %q, want ok", chunk0.Candidates[0].Content.Parts[0].Text) + } + + finalChunk := geminiChunkOf(t, events[2]) + if finalChunk.Candidates[0].FinishReason != "MAX_TOKENS" { + t.Errorf("Final finishReason = %q, want MAX_TOKENS", finalChunk.Candidates[0].FinishReason) + } + if finalChunk.UsageMetadata.CachedContentTokenCount != 80 { + t.Errorf("CachedContentTokenCount = %d, want 80", finalChunk.UsageMetadata.CachedContentTokenCount) + } + // 缓存明细透传进 StreamEvent.Usage(影响计费) + if events[2].Usage == nil || events[2].Usage.PromptTokensDetails == nil || events[2].Usage.PromptTokensDetails.CachedTokens != 80 { + t.Errorf("Final event usage details = %+v, want CachedTokens 80", events[2].Usage) + } +} + +func TestOpenAIToGeminiStreamConverter_NoDoneTrailer(t *testing.T) { + // 上游流未发送 [DONE] 直接结束,也应输出最终帧 + streamData := `data: {"choices":[{"index":0,"delta":{"content":"partial"},"finish_reason":null}]} + +` + + events := collectGeminiStreamEvents(t, streamData) + if len(events) != 2 { + t.Fatalf("Event count = %d, want 2", len(events)) + } + + // 无 finish_reason 时映射结果为空字符串(与旧实现一致,不默认 STOP) + finalChunk := geminiChunkOf(t, events[1]) + if finalChunk.Candidates[0].FinishReason != "" { + t.Errorf("Final finishReason = %q, want empty", finalChunk.Candidates[0].FinishReason) + } +} + +func TestOpenAIToGeminiStreamConverter_ContextCancelled(t *testing.T) { + converter := &OpenAIToGeminiStreamConverter{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + streamData := `data: {"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]} + +` + + err := converter.ConvertStreamResponse(ctx, nil, strings.NewReader(streamData), func(chunk any) error { + t.Fatal("chunkWriter should not be called after cancellation") + return nil + }) + if err == nil { + t.Fatal("Expected context error, got nil") + } + if err != context.Canceled { + t.Errorf("err = %v, want context.Canceled", err) + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request.go new file mode 100644 index 0000000..2cfb04b --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request.go @@ -0,0 +1,359 @@ +package oai_responses + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToResponsesRequestConverter 将 Chat Completions 请求转换为 Responses API 请求 +// (Responses API Bridge:chat 客户端 → Responses 原生上游)。 +type OpenAIToResponsesRequestConverter struct{} + +func (c *OpenAIToResponsesRequestConverter) ID() string { + return relayconvert.ConverterOpenAIChatToOpenAIResponses +} + +func (c *OpenAIToResponsesRequestConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToResponsesRequestConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *OpenAIToResponsesRequestConverter) Quality() relayconvert.RequestConverterQuality { + return relayconvert.RequestConverterQualityGood +} + +// ConvertRequest 将 Chat Completions 请求转换为 Responses API 请求。 +func (c *OpenAIToResponsesRequestConverter) ConvertRequest( + ctx context.Context, + info convmeta.Meta, + request any, +) (any, error) { + req, ok := request.(*dto.GeneralOpenAIRequest) + if !ok { + return nil, fmt.Errorf("expected *dto.GeneralOpenAIRequest, got %T", request) + } + + respReq := &dto.OpenAIResponsesRequest{Model: req.Model} + // 模型名:优先映射后的上游模型名,否则用客户端请求模型名 + if upstream := convmeta.UpstreamModelName(info); upstream != "" { + respReq.Model = upstream + } + + if instructions := c2rExtractInstructions(req.Messages); instructions != "" { + raw, err := json.Marshal(instructions) + if err != nil { + return nil, fmt.Errorf("marshal responses instructions: %w", err) + } + respReq.Instructions = raw + } + inputRaw, err := json.Marshal(c2rConvertMessagesToInput(req.Messages)) + if err != nil { + return nil, fmt.Errorf("marshal responses input: %w", err) + } + respReq.Input = inputRaw + + respReq.Stream = req.Stream + respReq.Temperature = req.Temperature + respReq.TopP = req.TopP + // prompt_cache_key 是官方 Responses API 参数,原样透传; + // presence/frequency penalty 不属于官方 Responses API,透传会被严格上游 + // (如 api.openai.com)以未知参数拒绝,chat 入站时不注入、静默丢弃。 + respReq.PromptCacheKey = req.PromptCacheKey + if maxTokens := c2rGetMaxTokens(req); maxTokens > 0 { + mt := uint(maxTokens) + respReq.MaxOutputTokens = &mt + } + if req.ResponseFormat != nil { + if tf := c2rBuildTextFormat(req.ResponseFormat); tf != nil { + raw, err := json.Marshal(tf) + if err != nil { + return nil, fmt.Errorf("marshal responses text format: %w", err) + } + respReq.Text = raw + } + } + // 桥接的响应不可被 chat 客户端经 previous_response_id 引用(chat 协议无此概念), + // 显式 store:false 避免上游无谓存储;渠道配置 DisableStore 时宿主 SanitizeFields 会删掉该字段 + respReq.Store = json.RawMessage("false") + if req.ReasoningEffort != "" { + respReq.Reasoning = &dto.Reasoning{Effort: req.ReasoningEffort, Summary: "detailed"} + } + if len(req.Tools) > 0 { + if respTools := c2rConvertTools(req.Tools); len(respTools) > 0 { + raw, err := json.Marshal(respTools) + if err != nil { + return nil, fmt.Errorf("marshal responses tools: %w", err) + } + respReq.Tools = raw + } + } + + // 服务端联网搜索:chat 的 web_search_options → Responses 的 web_search 工具项。 + // Responses 把搜索表达为工具,需与已转换的自定义函数合并进同一个 tools 数组。 + if spec := shared.DetectWebSearchFromOpenAI(req.WebSearchOptions); spec != nil { + tools := make([]any, 0, 1) + if len(respReq.Tools) > 0 { + if err := json.Unmarshal(respReq.Tools, &tools); err != nil { + return nil, fmt.Errorf("merge web_search into responses tools: %w", err) + } + } + tools = append(tools, spec.ToResponsesTool()) + raw, err := json.Marshal(tools) + if err != nil { + return nil, fmt.Errorf("marshal responses tools: %w", err) + } + respReq.Tools = raw + } + if req.ToolChoice != nil { + raw, err := json.Marshal(c2rConvertToolChoice(req.ToolChoice)) + if err != nil { + return nil, fmt.Errorf("marshal responses tool choice: %w", err) + } + respReq.ToolChoice = raw + } + if req.User != "" { + raw, err := json.Marshal(req.User) + if err != nil { + return nil, fmt.Errorf("marshal responses user: %w", err) + } + respReq.User = raw + } + if req.ParallelToolCalls != nil { + raw, err := json.Marshal(*req.ParallelToolCalls) + if err != nil { + return nil, fmt.Errorf("marshal responses parallel tool calls: %w", err) + } + respReq.ParallelToolCalls = raw + } + + return respReq, nil +} + +func c2rExtractInstructions(messages []dto.Message) string { + var parts []string + for _, msg := range messages { + if msg.Role == "system" || msg.Role == "developer" { + if text, ok := msg.Content.(string); ok && text != "" { + parts = append(parts, text) + } + } + } + if len(parts) == 0 { + return "" + } + instructions := parts[0] + for i := 1; i < len(parts); i++ { + instructions += "\n\n" + parts[i] + } + return instructions +} + +func c2rConvertMessagesToInput(messages []dto.Message) []any { + var input []any + for _, msg := range messages { + if msg.Role == "system" || msg.Role == "developer" { + continue + } + switch msg.Role { + case "user": + input = append(input, c2rMakeMessageItem("user", msg.Content)) + case "assistant": + if len(msg.ToolCalls) > 0 { + if msg.Content != nil { + if text, ok := msg.Content.(string); ok && text != "" { + input = append(input, c2rMakeMessageItem("assistant", text)) + } + } + for _, tc := range msg.ToolCalls { + input = append(input, map[string]any{ + "type": "function_call", "call_id": tc.ID, "name": tc.Function.Name, "arguments": tc.Function.Arguments, + }) + } + } else { + input = append(input, c2rMakeMessageItem("assistant", msg.Content)) + } + case "tool": + input = append(input, map[string]any{ + "type": "function_call_output", "call_id": msg.ToolCallID, "output": c2rContentToString(msg.Content), + }) + } + } + if len(input) == 0 { + return []any{} + } + return input +} + +func c2rMakeMessageItem(role string, content any) map[string]any { + if content == nil { + return map[string]any{"type": "message", "role": role, "content": []any{}} + } + switch v := content.(type) { + case string: + textType := "input_text" + if role == "assistant" { + textType = "output_text" + } + return map[string]any{"type": "message", "role": role, "content": []any{map[string]any{"type": textType, "text": v}}} + case []any: + parts := make([]any, 0, len(v)) + for _, item := range v { + part, ok := item.(map[string]any) + if !ok { + continue + } + if converted := c2rConvertContentPart(part, role); converted != nil { + parts = append(parts, converted) + } + } + return map[string]any{"type": "message", "role": role, "content": parts} + default: + b, err := json.Marshal(content) + if err != nil { + return map[string]any{"type": "message", "role": role, "content": []any{}} + } + var parts []any + if err := json.Unmarshal(b, &parts); err == nil { + converted := make([]any, 0, len(parts)) + for _, item := range parts { + part, ok := item.(map[string]any) + if !ok { + continue + } + if c := c2rConvertContentPart(part, role); c != nil { + converted = append(converted, c) + } + } + return map[string]any{"type": "message", "role": role, "content": converted} + } + return map[string]any{"type": "message", "role": role, "content": []any{}} + } +} + +func c2rConvertContentPart(part map[string]any, role string) map[string]any { + partType, _ := part["type"].(string) + switch partType { + case "text": + text, _ := part["text"].(string) + textType := "input_text" + if role == "assistant" { + textType = "output_text" + } + return map[string]any{"type": textType, "text": text} + case "image_url": + imgURL, _ := part["image_url"].(map[string]any) + if imgURL != nil { + result := map[string]any{"type": "input_image"} + if url, ok := imgURL["url"].(string); ok { + result["image_url"] = url + } + if detail, ok := imgURL["detail"].(string); ok { + result["detail"] = detail + } + return result + } + case "input_audio": + data, _ := part["data"].(string) + format, _ := part["format"].(string) + return map[string]any{"type": "input_audio", "data": data, "format": format} + case "file": + result := map[string]any{"type": "input_file"} + if fileData, ok := part["file_data"].(string); ok { + result["file_data"] = fileData + } + if filename, ok := part["filename"].(string); ok { + result["filename"] = filename + } + return result + } + return nil +} + +func c2rConvertTools(tools []dto.Tool) []any { + result := make([]any, 0, len(tools)) + for _, tool := range tools { + if tool.Type == "function" { + result = append(result, map[string]any{ + "type": "function", "name": tool.Function.Name, "description": tool.Function.Description, "parameters": tool.Function.Parameters, + }) + } + } + return result +} + +func c2rConvertToolChoice(toolChoice any) any { + b, err := json.Marshal(toolChoice) + if err != nil { + return toolChoice + } + var tc map[string]any + if err := json.Unmarshal(b, &tc); err != nil { + return toolChoice + } + if tc["type"] == "function" { + if fn, ok := tc["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "function", "name": name} + } + } + } + return tc +} + +func c2rContentToString(content any) string { + if content == nil { + return "" + } + if s, ok := content.(string); ok { + return s + } + b, _ := json.Marshal(content) + return string(b) +} + +func c2rGetMaxTokens(req *dto.GeneralOpenAIRequest) int { + max := 0 + if req.MaxTokens != nil && *req.MaxTokens > 0 { + max = *req.MaxTokens + } + if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens > max { + max = *req.MaxCompletionTokens + } + return max +} + +// c2rBuildTextFormat 将 chat 的 response_format 转换为 Responses 的 text 配置。 +// chat 的 json_schema 为嵌套 {type,json_schema:{name,schema,strict}}, +// Responses 的 format 为扁平 {type,name,schema,strict}——需解包提升,不能原样塞入。 +// 其余类型(json_object)两侧同形;无法识别时返回 nil 不映射。 +func c2rBuildTextFormat(rf *dto.ResponseFormat) map[string]any { + if rf == nil { + return nil + } + switch rf.Type { + case "json_object": + return map[string]any{"format": map[string]any{"type": "json_object"}} + case "json_schema": + format := map[string]any{"type": "json_schema"} + if js, ok := rf.JSONSchema.(map[string]any); ok { + for _, k := range []string{"name", "schema", "strict"} { + if v, ok := js[k]; ok { + format[k] = v + } + } + } + return map[string]any{"format": format} + default: + return nil + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request_test.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request_test.go new file mode 100644 index 0000000..f582dda --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_request_test.go @@ -0,0 +1,203 @@ +package oai_responses + +import ( + "context" + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// convertChatBody 解析 chat 请求 JSON、执行转换并把结果 Responses 请求重新序列化为 map。 +func convertChatBody(t *testing.T, info convmeta.Meta, body string) map[string]any { + t.Helper() + var req dto.GeneralOpenAIRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("parse chat request: %v", err) + } + conv := &OpenAIToResponsesRequestConverter{} + out, err := conv.ConvertRequest(context.Background(), info, &req) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + raw, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal responses request: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + return m +} + +func TestOpenAIToResponsesRequest_WrongType(t *testing.T) { + conv := &OpenAIToResponsesRequestConverter{} + if _, err := conv.ConvertRequest(context.Background(), &convmeta.Values{}, 42); err == nil { + t.Fatal("wrong input type should be rejected") + } +} + +// TestOpenAIToResponsesRequest_PenaltyDropped chat 入站转 Responses 出站时 +// 丢弃官方不支持的 presence/frequency penalty(透传会被严格上游拒绝), +// 保留官方参数 prompt_cache_key。 +func TestOpenAIToResponsesRequest_PenaltyDropped(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"frequency_penalty":0.5,"presence_penalty":0.2,"prompt_cache_key":"abc"}`) + if _, ok := m["frequency_penalty"]; ok { + t.Error("frequency_penalty should be dropped (not official Responses API)") + } + if _, ok := m["presence_penalty"]; ok { + t.Error("presence_penalty should be dropped (not official Responses API)") + } + if m["prompt_cache_key"] != "abc" { + t.Errorf("prompt_cache_key = %v, want abc", m["prompt_cache_key"]) + } + if _, ok := m["input"]; !ok { + t.Error("input should be present") + } +} + +// TestOpenAIToResponsesRequest_TextFormatUnpack chat response_format 转 Responses +// text.format:json_schema 需解包为扁平结构(不能原样塞入嵌套形状)。 +func TestOpenAIToResponsesRequest_TextFormatUnpack(t *testing.T) { + t.Run("json_schema", func(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],`+ + `"response_format":{"type":"json_schema","json_schema":{"name":"out","schema":{"type":"object"},"strict":true}}}`) + text, _ := m["text"].(map[string]any) + format, _ := text["format"].(map[string]any) + if format["type"] != "json_schema" { + t.Fatalf("format = %v", format) + } + if format["name"] != "out" { + t.Errorf("format.name = %v, want out", format["name"]) + } + if _, nested := format["json_schema"]; nested { + t.Error("format should be flat(json_schema 嵌套必须解包)") + } + if s, _ := format["schema"].(map[string]any); s == nil { + t.Errorf("format.schema = %v", format["schema"]) + } + }) + t.Run("json_object", func(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_object"}}`) + text, _ := m["text"].(map[string]any) + format, _ := text["format"].(map[string]any) + if format["type"] != "json_object" { + t.Errorf("format = %v", format) + } + }) +} + +// TestOpenAIToResponsesRequest_StoreFalse 桥接方向显式 store:false: +// chat 客户端无法经 previous_response_id 引用响应,无需上游存储。 +func TestOpenAIToResponsesRequest_StoreFalse(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + if v, ok := m["store"]; !ok || v != false { + t.Errorf("store = %v(%T), want explicit false", m["store"], m["store"]) + } +} + +// TestOpenAIToResponsesRequest_InstructionsAndInput system/developer 消息合并为 instructions +// (\n\n 连接),其余消息转为 input 数组(user=input_text / assistant=output_text)。 +func TestOpenAIToResponsesRequest_InstructionsAndInput(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[`+ + `{"role":"system","content":"sys one"},`+ + `{"role":"developer","content":"dev two"},`+ + `{"role":"user","content":"hi"},`+ + `{"role":"assistant","content":"hello"}]}`) + if m["instructions"] != "sys one\n\ndev two" { + t.Errorf("instructions = %v", m["instructions"]) + } + input, _ := m["input"].([]any) + if len(input) != 2 { + t.Fatalf("input = %v, want 2 items", input) + } + userItem, _ := input[0].(map[string]any) + if userItem["type"] != "message" || userItem["role"] != "user" { + t.Errorf("input[0] = %v", userItem) + } + userParts, _ := userItem["content"].([]any) + up0, _ := userParts[0].(map[string]any) + if up0["type"] != "input_text" || up0["text"] != "hi" { + t.Errorf("input[0].content = %v", userParts) + } + asstItem, _ := input[1].(map[string]any) + asstParts, _ := asstItem["content"].([]any) + ap0, _ := asstParts[0].(map[string]any) + if ap0["type"] != "output_text" || ap0["text"] != "hello" { + t.Errorf("input[1].content = %v", asstParts) + } +} + +// TestOpenAIToResponsesRequest_ToolCallHistory assistant.tool_calls → function_call 项, +// tool 消息 → function_call_output 项。 +func TestOpenAIToResponsesRequest_ToolCallHistory(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[`+ + `{"role":"user","content":"list files"},`+ + `{"role":"assistant","content":null,"tool_calls":[{"id":"call_a","type":"function","function":{"name":"shell","arguments":"{\"cmd\":\"ls\"}"}}]},`+ + `{"role":"tool","tool_call_id":"call_a","content":"a.go b.go"}]}`) + input, _ := m["input"].([]any) + if len(input) != 3 { + t.Fatalf("input = %v, want 3 items (user message + function_call + function_call_output)", input) + } + fc, _ := input[1].(map[string]any) + if fc["type"] != "function_call" || fc["call_id"] != "call_a" || fc["name"] != "shell" || fc["arguments"] != `{"cmd":"ls"}` { + t.Errorf("input[1] = %v", fc) + } + fco, _ := input[2].(map[string]any) + if fco["type"] != "function_call_output" || fco["call_id"] != "call_a" || fco["output"] != "a.go b.go" { + t.Errorf("input[2] = %v", fco) + } +} + +// TestOpenAIToResponsesRequest_ParamMapping 常规参数映射: +// max_tokens/max_completion_tokens 取较大者、reasoning_effort、tools、tool_choice 解包、 +// user、parallel_tool_calls、stream。 +func TestOpenAIToResponsesRequest_ParamMapping(t *testing.T) { + m := convertChatBody(t, &convmeta.Values{}, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}],`+ + `"stream":true,"temperature":0.4,"top_p":0.8,"max_tokens":100,"max_completion_tokens":300,`+ + `"reasoning_effort":"high","user":"u1","parallel_tool_calls":false,`+ + `"tools":[{"type":"function","function":{"name":"get_weather","description":"d","parameters":{"type":"object"}}}],`+ + `"tool_choice":{"type":"function","function":{"name":"get_weather"}}}`) + if m["stream"] != true { + t.Errorf("stream = %v", m["stream"]) + } + if m["temperature"] != 0.4 || m["top_p"] != 0.8 { + t.Errorf("temperature/top_p = %v/%v", m["temperature"], m["top_p"]) + } + if m["max_output_tokens"] != float64(300) { + t.Errorf("max_output_tokens = %v, want 300 (取 max_tokens 与 max_completion_tokens 较大者)", m["max_output_tokens"]) + } + reasoning, _ := m["reasoning"].(map[string]any) + if reasoning["effort"] != "high" || reasoning["summary"] != "detailed" { + t.Errorf("reasoning = %v", reasoning) + } + if m["user"] != "u1" { + t.Errorf("user = %v", m["user"]) + } + if m["parallel_tool_calls"] != false { + t.Errorf("parallel_tool_calls = %v, want false", m["parallel_tool_calls"]) + } + tools, _ := m["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools = %v", tools) + } + tool0, _ := tools[0].(map[string]any) + if tool0["type"] != "function" || tool0["name"] != "get_weather" { + t.Errorf("tools[0] = %v (Responses tools 为扁平结构)", tool0) + } + tc, _ := m["tool_choice"].(map[string]any) + if tc["type"] != "function" || tc["name"] != "get_weather" { + t.Errorf("tool_choice = %v (Responses tool_choice 为扁平结构)", tc) + } +} + +// TestOpenAIToResponsesRequest_ModelMapping 已附加渠道信息时使用映射后的上游模型名。 +func TestOpenAIToResponsesRequest_ModelMapping(t *testing.T) { + info := &convmeta.Values{OriginModelName: "gpt-4o", UpstreamModelName: "gpt-4o-upstream", ChannelMetaAttached: true} + m := convertChatBody(t, info, `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + if m["model"] != "gpt-4o-upstream" { + t.Errorf("model = %v, want gpt-4o-upstream", m["model"]) + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go new file mode 100644 index 0000000..c3377fe --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go @@ -0,0 +1,199 @@ +package oai_responses + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToResponsesResponseConverter 将 Chat Completions 非流式响应转换为 Responses API 响应 +// (Responses 入站 → chat 上游方向的响应合成)。 +type OpenAIToResponsesResponseConverter struct{} + +func (c *OpenAIToResponsesResponseConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToOAIResponses +} + +func (c *OpenAIToResponsesResponseConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToResponsesResponseConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *OpenAIToResponsesResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 Chat Completions 非流式响应转换为 Responses API 非流式响应。 +func (c *OpenAIToResponsesResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + chatResp, ok := response.(*dto.ChatCompletionResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.ChatCompletionResponse, got %T", response) + } + return chatCompletionToResponsesResponse(chatResp, info), nil +} + +// responsesRequestEcho 合成 Responses 响应时需回显(echo)的请求参数 +type responsesRequestEcho struct { + temperature *float64 + topP *float64 + maxOutputTokens *int + instructions any +} + +// extractResponsesRequestEcho 从 ResponsesStash 能力接口(宿主 stash 的请求快照)提取 +// 合成响应应 echo 的请求参数。info 未实现该接口或快照缺失(直连路径/异常)时 +// 回退 OpenAI 默认值(temperature=1.0 / top_p=1.0,其余 nil)。 +func extractResponsesRequestEcho(info convmeta.Meta) responsesRequestEcho { + echo := responsesRequestEcho{temperature: float64Ptr(1.0), topP: float64Ptr(1.0)} + stash, ok := info.(convmeta.ResponsesStash) + if !ok { + return echo + } + rr := stash.StashedResponsesRequest() + if rr == nil { + return echo + } + if rr.Temperature != nil { + echo.temperature = rr.Temperature + } + if rr.TopP != nil { + echo.topP = rr.TopP + } + if rr.MaxOutputTokens != nil { + m := int(*rr.MaxOutputTokens) + echo.maxOutputTokens = &m + } + if len(rr.Instructions) > 0 { + echo.instructions = json.RawMessage(rr.Instructions) + } + return echo +} + +// chatCompletionToResponsesResponse 将 Chat Completions 响应转换为 Responses API 响应 +func chatCompletionToResponsesResponse(chatResp *dto.ChatCompletionResponse, info convmeta.Meta) *dto.OpenAIResponsesResponse { + modelName := "" + if info != nil { + modelName = info.GetOriginModelName() + } + echo := extractResponsesRequestEcho(info) + + // 构建 output + output := make([]dto.ResponsesOutput, 0) + for _, choice := range chatResp.Choices { + // 思考内容 → reasoning 输出项(排在 message 之前,与 Responses API 的真实 + // 输出顺序一致)。此前一律跳过,推理模型(DeepSeek-R1 / o 系列等)的思考过程 + // 经本方向转换后会静默消失。 + if choice.Message.ReasoningContent != nil { + if item := shared.BuildResponsesReasoningOutput(chatResp.ID, *choice.Message.ReasoningContent); item != nil { + output = append(output, *item) + } + } + + // 文本内容 + content := make([]dto.ResponsesOutputContent, 0) + if choice.Message.Content != nil { + var textContent string + switch v := choice.Message.Content.(type) { + case string: + textContent = v + default: + b, _ := json.Marshal(v) + textContent = string(b) + } + if textContent != "" { + content = append(content, dto.ResponsesOutputContent{ + Type: "output_text", + Text: textContent, + Annotations: []dto.ResponsesAnnotation{}, + }) + } + } + + msgOutput := dto.ResponsesOutput{ + Type: "message", + ID: fmt.Sprintf("msg_%s", chatResp.ID), + Status: "completed", + Role: "assistant", + Content: content, + } + output = append(output, msgOutput) + + // 工具调用 + for _, tc := range choice.Message.ToolCalls { + output = append(output, dto.ResponsesOutput{ + Type: "function_call", + ID: tc.ID, + CallID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + Status: "completed", + }) + } + } + + // usage 明细:上游 chat 响应可能不携带 details(nil 指针),缺失时按零值合成 + inputDetails := &dto.InputTokenDetails{} + if d := chatResp.Usage.PromptTokensDetails; d != nil { + inputDetails.CachedTokens = d.CachedTokens + inputDetails.CacheWriteTokens = d.CacheWriteTokens + inputDetails.AudioTokens = d.AudioTokens + } + outputDetails := &dto.OutputTokenDetails{} + if d := chatResp.Usage.CompletionTokenDetails; d != nil { + outputDetails.ReasoningTokens = d.ReasoningTokens + outputDetails.AcceptedPredictionTokens = d.AcceptedPredictionTokens + outputDetails.RejectedPredictionTokens = d.RejectedPredictionTokens + } + + return &dto.OpenAIResponsesResponse{ + ID: fmt.Sprintf("resp_%s", chatResp.ID), + Object: "response", + CreatedAt: int(chatResp.Created), + CompletedAt: int(chatResp.Created) + 1, + Status: json.RawMessage(`"completed"`), + Error: nil, + IncompleteDetails: nil, + Instructions: echo.instructions, + MaxOutputTokens: echo.maxOutputTokens, + Model: modelName, + Output: output, + ParallelToolCalls: true, + PreviousResponseID: nil, + Reasoning: &dto.ResponsesReasoning{Effort: nil, Summary: nil}, + // store:false 是真实语义:合成响应不落上游存储,客户端不可经 GET /v1/responses/{id} retrieve + Store: false, + Temperature: echo.temperature, + Text: &dto.ResponsesText{Format: dto.ResponsesTextFormat{Type: "text"}}, + ToolChoice: "auto", + Tools: make([]any, 0), + TopP: echo.topP, + Truncation: "disabled", + User: nil, + Metadata: make(map[string]any), + Usage: &dto.ResponsesUsage{ + InputTokens: chatResp.Usage.PromptTokens, + OutputTokens: chatResp.Usage.CompletionTokens, + TotalTokens: chatResp.Usage.TotalTokens, + InputTokensDetails: inputDetails, + OutputTokenDetails: outputDetails, + }, + } +} + +// float64Ptr 返回 float64 的指针 +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go new file mode 100644 index 0000000..3d088c0 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go @@ -0,0 +1,583 @@ +package oai_responses + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// OpenAIToResponsesStreamConverter 将 Chat Completions SSE 流转换为 Responses API 事件流 +// (Responses 入站 → chat 上游方向的流式响应合成)。 +// 每帧输出 *relayconvert.StreamEvent{Event: "response.xxx", Data: 负载}(事件帧), +// 最终 usage 挂在 response.completed 事件的 StreamEvent.Usage 上(宿主由此提取计费用量)。 +type OpenAIToResponsesStreamConverter struct{} + +func (c *OpenAIToResponsesStreamConverter) ID() string { + return relayconvert.ResponseConverterOAIChatToOAIResponsesStream +} + +func (c *OpenAIToResponsesStreamConverter) From() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *OpenAIToResponsesStreamConverter) To() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *OpenAIToResponsesStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Chat Completions SSE 流转换为 Responses API SSE 事件流。 +// reader 提供上游 chat SSE,转换后的事件经 chunkWriter 以 *relayconvert.StreamEvent 写出。 +func (c *OpenAIToResponsesStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + modelName := "" + if info != nil { + modelName = info.GetOriginModelName() + } + echo := extractResponsesRequestEcho(info) + respID := fmt.Sprintf("resp_%d", time.Now().UnixNano()) + msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) + createdAt := int(time.Now().Unix()) + + emit := func(event string, data map[string]any, usage *dto.UsageWithDetails) error { + return chunkWriter(&relayconvert.StreamEvent{Event: event, Data: data, Usage: usage}) + } + + var usage dto.UsageWithDetails + var contentBuilder strings.Builder + sentCreated := false + sentTextDone := false + parsedChunks := 0 + sawChoices := false + outputIndex := 0 // message 项固定占用 0,文本事件始终挂在这里 + itemIdx := 1 // 后续输出项(reasoning / tool call)的索引分配器 + contentIndex := 0 + toolCallIndexByID := make(map[string]int) + toolCallArgsByID := make(map[string]string) + toolCallNameByID := make(map[string]string) + // 通过 index 追踪 tool call ID(OpenAI 流式中后续 chunk 的 ID 为空,只有 index) + toolCallIDByIndex := make(map[int]string) + + // 思考内容:chat 的 reasoning_content 增量必须落成独立的 reasoning 输出项 + // (output_item.added / .done + summary 文本),客户端才能在下一轮把它放进 + // input 回传——只发孤儿 delta 的话历史里永远没有 reasoning 项, + // DeepSeek 等要求回传 reasoning_content 的 thinking 上游会直接 400。 + var reasoningText strings.Builder + reasoningID := "" + reasoningItemIdx := -1 + closeReasoningItem := func() error { + if reasoningItemIdx < 0 { + return nil + } + text := reasoningText.String() + if err := emit("response.reasoning_summary_text.done", map[string]any{ + "type": "response.reasoning_summary_text.done", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "text": text, + }, nil); err != nil { + return err + } + return emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": reasoningItemIdx, + "item": map[string]any{ + "type": "reasoning", + "id": reasoningID, + "status": "completed", + "summary": []map[string]any{{ + "type": "summary_text", + "text": text, + }}, + }, + }, nil) + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" { + continue + } + if strings.HasPrefix(line, "event:") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + if data == "" { + continue + } + + // 检测 SSE 流中内嵌的上游错误对象:部分聚合商出错时返回 HTTP 200 + SSE, + // 错误信息夹在 data 行里({"error":{...}})。不识别会被当作解析失败静默丢弃, + // 最终合成空的 response.completed(客户端表现为"成功但无内容")。 + // 错误的透传/回写由宿主负责,此处直接终止转换。 + if errBody, ok := extractStreamEmbeddedError([]byte(data)); ok { + return fmt.Errorf("upstream embedded error in SSE stream: %.500s", string(errBody)) + } + + var chunk dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + // 解析失败不中断流(上游可能夹杂非 chat 格式行),后置的协议守卫兜底 + continue + } + parsedChunks++ + if len(chunk.Choices) > 0 { + sawChoices = true + } + + // 第一个 chunk:发送 response.created + output_item.added + content_part.added + if !sentCreated { + if chunk.ID != "" { + respID = fmt.Sprintf("resp_%s", chunk.ID) + msgID = fmt.Sprintf("msg_%s", chunk.ID) + } + if chunk.Created > 0 { + createdAt = int(chunk.Created) + } + if chunk.Model != "" && !isModelMapped(info) { + modelName = chunk.Model + } + + if err := emit("response.created", map[string]any{ + "type": "response.created", + "response": buildResponsesObjectMap(respID, createdAt, "in_progress", modelName, []any{}, nil, nil, echo), + }, nil); err != nil { + return err + } + + if err := emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "in_progress", + "role": "assistant", + "content": []any{}, + }, + }, nil); err != nil { + return err + } + + if err := emit("response.content_part.added", map[string]any{ + "type": "response.content_part.added", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": "", + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + + sentCreated = true + } + + // 提取 usage(含 token 明细,透传给完成事件) + if chunk.Usage != nil { + usage.PromptTokens = chunk.Usage.PromptTokens + usage.CompletionTokens = chunk.Usage.CompletionTokens + usage.TotalTokens = chunk.Usage.TotalTokens + usage.PromptTokensDetails = chunk.Usage.PromptTokensDetails + usage.CompletionTokenDetails = chunk.Usage.CompletionTokenDetails + } + + // 处理 choices delta + for _, choice := range chunk.Choices { + // 文本内容 delta + if choice.Delta.Content != nil { + var deltaText string + switch v := choice.Delta.Content.(type) { + case string: + deltaText = v + } + if deltaText != "" { + contentBuilder.WriteString(deltaText) + if err := emit("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "delta": deltaText, + }, nil); err != nil { + return err + } + } + } + + // 推理内容:懒开一个 reasoning 输出项(首个增量到达时), + // 后续增量以该项的 id / output_index 挂载 + if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" { + if reasoningItemIdx < 0 { + reasoningItemIdx = itemIdx + reasoningID = fmt.Sprintf("rs_%s", respID) + itemIdx++ + if err := emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": reasoningItemIdx, + "item": map[string]any{ + "type": "reasoning", + "id": reasoningID, + "status": "in_progress", + "summary": []any{}, + }, + }, nil); err != nil { + return err + } + } + reasoningText.WriteString(*choice.Delta.ReasoningContent) + if err := emit("response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "delta": *choice.Delta.ReasoningContent, + }, nil); err != nil { + return err + } + } + + // 工具调用 + for _, tc := range choice.Delta.ToolCalls { + callID := tc.ID + + // 新 tool call:有 ID 和 name + if callID != "" && tc.Function.Name != "" { + // 前一项若是 reasoning,先收口再开工具项 + if err := closeReasoningItem(); err != nil { + return err + } + // 记录 index → callID 映射,用于后续参数 chunk 的查找 + toolCallIDByIndex[tc.Index] = callID + + // 先关闭文本 content part + if !sentTextDone { + finishedText := contentBuilder.String() + if err := emitTextDoneEvents(emit, msgID, outputIndex, contentIndex, finishedText); err != nil { + return err + } + sentTextDone = true + } + + toolCallIndexByID[callID] = itemIdx + toolCallNameByID[callID] = tc.Function.Name + toolCallArgsByID[callID] = "" + + if err := emit("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": itemIdx, + "item": map[string]any{ + "type": "function_call", + "id": callID, + "call_id": callID, + "name": tc.Function.Name, + "status": "in_progress", + }, + }, nil); err != nil { + return err + } + itemIdx++ + } + + // 参数 chunk:ID 可能为空,通过 index 查找对应的 callID + if callID == "" { + callID = toolCallIDByIndex[tc.Index] + } + if callID == "" { + continue + } + + // 工具调用 arguments 增量 + if tc.Function.Arguments != "" { + toolCallArgsByID[callID] += tc.Function.Arguments + if err := emit("response.function_call_arguments.delta", map[string]any{ + "type": "response.function_call_arguments.delta", + "item_id": callID, + "output_index": toolCallIndexByID[callID], + "delta": tc.Function.Arguments, + }, nil); err != nil { + return err + } + } + } + + // finish_reason + if choice.FinishReason != nil && *choice.FinishReason != "" { + if err := closeReasoningItem(); err != nil { + return err + } + finishedText := contentBuilder.String() + + // 关闭文本 content part(如果尚未关闭) + if !sentTextDone { + if err := emitTextDoneEvents(emit, msgID, outputIndex, contentIndex, finishedText); err != nil { + return err + } + } + + // 发送每个 tool call 的 function_call_arguments.done + output_item.done + for tcID, tcIdx := range toolCallIndexByID { + if err := emit("response.function_call_arguments.done", map[string]any{ + "type": "response.function_call_arguments.done", + "item_id": tcID, + "output_index": tcIdx, + "arguments": toolCallArgsByID[tcID], + }, nil); err != nil { + return err + } + if err := emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": tcIdx, + "item": map[string]any{ + "type": "function_call", + "id": tcID, + "call_id": tcID, + "name": toolCallNameByID[tcID], + "arguments": toolCallArgsByID[tcID], + "status": "completed", + }, + }, nil); err != nil { + return err + } + } + } + } + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return fmt.Errorf("stream scanner error: %w", err) + } + + // 上游流始终不是 chat 格式(全程无 choices 且无内容/工具调用/usage)时不静默合成空响应: + // 典型形态包括 Responses/Claude 格式 SSE(JSON 能解析为空 chat chunk)、非流式 JSON 体、 + // 空 body 等。假成功的空 response.completed 会让客户端"成功但无内容",必须报错由宿主 + // 驱动重试换渠道/健康上报。 + if !sawChoices && contentBuilder.Len() == 0 && len(toolCallIndexByID) == 0 && usage.TotalTokens == 0 { + return fmt.Errorf("upstream stream is not chat completions format: %d chunks parsed, none contained choices", parsedChunks) + } + + // 估算 usage(4 字符/token;流中断的保守估算兜底由宿主处理) + if usage.CompletionTokens == 0 { + text := contentBuilder.String() + if len(text) > 0 { + usage.CompletionTokens = len(text) / 4 + } + } + if usage.TotalTokens == 0 { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + + // 构建 response.completed 的 output 数组(包含文本消息 + reasoning + 所有 tool call) + finalOutput := make([]map[string]any, 0) + if !sentTextDone || contentBuilder.Len() > 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": contentBuilder.String(), + "annotations": []any{}, + }, + }, + }) + } + // reasoning 项(流中途异常未收口时的兜底;正常路径 closeReasoningItem 已发过 done 事件, + // completed 的 output 数组仍需携带完整项,客户端据此重建下一轮的 input 历史) + if reasoningItemIdx >= 0 { + finalOutput = append(finalOutput, map[string]any{ + "type": "reasoning", + "id": reasoningID, + "status": "completed", + "summary": []map[string]any{{ + "type": "summary_text", + "text": reasoningText.String(), + }}, + }) + } + for tcID := range toolCallIndexByID { + finalOutput = append(finalOutput, map[string]any{ + "type": "function_call", + "id": tcID, + "call_id": tcID, + "name": toolCallNameByID[tcID], + "arguments": toolCallArgsByID[tcID], + "status": "completed", + }) + } + + // response.completed(最终 usage 同时挂在 StreamEvent.Usage 上供宿主提取) + completedAt := int(time.Now().Unix()) + finalUsage := usage + return emit("response.completed", map[string]any{ + "type": "response.completed", + "response": buildResponsesObjectMap(respID, createdAt, "completed", modelName, finalOutput, buildResponsesUsageMap(&usage), &completedAt, echo), + }, &finalUsage) +} + +// emitTextDoneEvents 关闭文本 content part:依次发送 response.output_text.done、 +// response.content_part.done、response.output_item.done(message completed)。 +func emitTextDoneEvents(emit func(string, map[string]any, *dto.UsageWithDetails) error, msgID string, outputIndex int, contentIndex int, finishedText string) error { + if err := emit("response.output_text.done", map[string]any{ + "type": "response.output_text.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "text": finishedText, + }, nil); err != nil { + return err + } + if err := emit("response.content_part.done", map[string]any{ + "type": "response.content_part.done", + "item_id": msgID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]any{ + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, nil); err != nil { + return err + } + return emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]any{ + "type": "message", + "id": msgID, + "status": "completed", + "role": "assistant", + "content": []map[string]any{ + { + "type": "output_text", + "text": finishedText, + "annotations": []any{}, + }, + }, + }, + }, nil) +} + +// extractStreamEmbeddedError 检测 SSE data 行中内嵌的上游错误对象(存在 "error" 键且值非 null)。 +// 对流式 chunk 逐行检测,并排除 "error":null(部分供应商的正常 chunk 会携带空 error 字段)。 +func extractStreamEmbeddedError(data []byte) (json.RawMessage, bool) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, false + } + errBody, ok := raw["error"] + if !ok { + return nil, false + } + trimmed := string(bytes.TrimSpace(errBody)) + if trimmed == "" || trimmed == "null" { + return nil, false + } + return errBody, true +} + +// buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map +// (私有等价实现,宿主导出版 BuildResponsesObjectMap 仍被其他适配器复用)。 +// 请求参数从 echo 快照回显(快照缺失时回退默认值); +// store 恒为 false——合成响应不落上游存储,客户端不可经生命周期端点 retrieve。 +func buildResponsesObjectMap(respID string, createdAt int, status string, model string, output any, usageObj map[string]any, completedAt *int, echo responsesRequestEcho) map[string]any { + m := map[string]any{ + "id": respID, + "object": "response", + "created_at": createdAt, + "status": status, + "error": nil, + "incomplete_details": nil, + "instructions": echo.instructions, + "max_output_tokens": echo.maxOutputTokens, + "model": model, + "output": output, + "parallel_tool_calls": true, + "previous_response_id": nil, + "reasoning": map[string]any{"effort": nil, "summary": nil}, + "store": false, + "temperature": *echo.temperature, + "text": map[string]any{"format": map[string]any{"type": "text"}}, + "tool_choice": "auto", + "tools": []any{}, + "top_p": *echo.topP, + "truncation": "disabled", + "user": nil, + "metadata": map[string]any{}, + } + if completedAt != nil { + m["completed_at"] = *completedAt + } + if usageObj != nil { + m["usage"] = usageObj + } + return m +} + +// buildResponsesUsageMap 构建 Responses API usage 对象 +// (私有等价实现,宿主导出版 BuildResponsesUsageMap 仍被其他适配器复用)。 +func buildResponsesUsageMap(usage *dto.UsageWithDetails) map[string]any { + inputDetails := map[string]any{"cached_tokens": 0} + outputDetails := map[string]any{"reasoning_tokens": 0} + if usage.PromptTokensDetails != nil { + inputDetails = map[string]any{ + "cached_tokens": usage.PromptTokensDetails.CachedTokens, + "cache_write_tokens": usage.PromptTokensDetails.CacheWriteTokens, + "audio_tokens": usage.PromptTokensDetails.AudioTokens, + } + } + if usage.CompletionTokenDetails != nil { + outputDetails = map[string]any{ + "reasoning_tokens": usage.CompletionTokenDetails.ReasoningTokens, + "audio_tokens": usage.CompletionTokenDetails.AudioTokens, + "accepted_prediction_tokens": usage.CompletionTokenDetails.AcceptedPredictionTokens, + "rejected_prediction_tokens": usage.CompletionTokenDetails.RejectedPredictionTokens, + } + } + return map[string]any{ + "input_tokens": usage.PromptTokens, + "output_tokens": usage.CompletionTokens, + "total_tokens": usage.TotalTokens, + "input_tokens_details": inputDetails, + "output_tokens_details": outputDetails, + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go new file mode 100644 index 0000000..4b04b92 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go @@ -0,0 +1,325 @@ +package oai_responses + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// runChatToResponsesStream 执行 chat SSE → Responses 事件流转换并收集全部 StreamEvent。 +func runChatToResponsesStream(t *testing.T, info convmeta.Meta, sse string) ([]*relayconvert.StreamEvent, error) { + t.Helper() + conv := &OpenAIToResponsesStreamConverter{} + var events []*relayconvert.StreamEvent + err := conv.ConvertStreamResponse(context.Background(), info, strings.NewReader(sse), func(chunk any) error { + ev, ok := chunk.(*relayconvert.StreamEvent) + if !ok { + t.Fatalf("expected *relayconvert.StreamEvent, got %T", chunk) + } + events = append(events, ev) + return nil + }) + return events, err +} + +func eventNames(events []*relayconvert.StreamEvent) []string { + names := make([]string, 0, len(events)) + for _, ev := range events { + names = append(names, ev.Event) + } + return names +} + +func eventData(t *testing.T, ev *relayconvert.StreamEvent) map[string]any { + t.Helper() + m, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("event %s data is %T, want map", ev.Event, ev.Data) + } + return m +} + +// TestOpenAIToResponsesStream_TextToolCallsUsage 完整链路:文本 + 工具调用 + usage。 +// 断言事件名顺序、关键负载与最终 usage(含缓存明细透传)。 +func TestOpenAIToResponsesStream_TextToolCallsUsage(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"}}]}`, + ``, + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[{"index":0,"delta":{"content":"lo"}}]}`, + ``, + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`, + ``, + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"sh\"}"}}]}}]}`, + ``, + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + ``, + `data: {"id":"abc","object":"chat.completion.chunk","created":111,"model":"gpt-4o-upstream","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3,"total_tokens":10,"prompt_tokens_details":{"cached_tokens":4},"completion_tokens_details":{"reasoning_tokens":2}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + events, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + + wantNames := []string{ + "response.created", + "response.output_item.added", // message + "response.content_part.added", // 空文本 part + "response.output_text.delta", // "Hel" + "response.output_text.delta", // "lo" + "response.output_text.done", // 工具调用开始前关闭文本 part + "response.content_part.done", + "response.output_item.done", // message completed + "response.output_item.added", // function_call + "response.function_call_arguments.delta", + "response.function_call_arguments.done", // finish_reason 触发 + "response.output_item.done", // function_call completed + "response.completed", + } + gotNames := eventNames(events) + if len(gotNames) != len(wantNames) { + t.Fatalf("event count = %d, want %d\ngot: %v", len(gotNames), len(wantNames), gotNames) + } + for i := range wantNames { + if gotNames[i] != wantNames[i] { + t.Fatalf("event[%d] = %s, want %s\ngot: %v", i, gotNames[i], wantNames[i], gotNames) + } + } + + // response.created 负载:ID/created 取自首个 chunk;未映射时模型名取上游 chunk 的模型名; + // store 恒 false;echo 缺失回退默认 temperature/top_p=1.0 + created := eventData(t, events[0]) + if created["type"] != "response.created" { + t.Errorf("created type = %v", created["type"]) + } + respObj, _ := created["response"].(map[string]any) + if respObj["id"] != "resp_abc" || respObj["status"] != "in_progress" { + t.Errorf("created response = %v", respObj) + } + if respObj["created_at"] != 111 && respObj["created_at"] != float64(111) { + t.Errorf("created_at = %v, want 111", respObj["created_at"]) + } + if respObj["model"] != "gpt-4o-upstream" { + t.Errorf("model = %v, want gpt-4o-upstream(未映射时用上游模型名)", respObj["model"]) + } + if respObj["store"] != false { + t.Errorf("store = %v, want false", respObj["store"]) + } + if respObj["temperature"] != 1.0 { + t.Errorf("temperature = %v, want 1.0 (echo 默认值)", respObj["temperature"]) + } + + // 文本 delta 负载 + delta1 := eventData(t, events[3]) + if delta1["delta"] != "Hel" || delta1["item_id"] != "msg_abc" { + t.Errorf("text delta = %v", delta1) + } + + // output_text.done 携带完整文本 + textDone := eventData(t, events[5]) + if textDone["text"] != "Hello" { + t.Errorf("output_text.done text = %v, want Hello", textDone["text"]) + } + + // function_call added 负载 + fcAdded := eventData(t, events[8]) + fcItem, _ := fcAdded["item"].(map[string]any) + if fcItem["type"] != "function_call" || fcItem["call_id"] != "call_1" || fcItem["name"] != "get_weather" || fcItem["status"] != "in_progress" { + t.Errorf("function_call added item = %v", fcItem) + } + if fcAdded["output_index"] != 1 { + t.Errorf("function_call output_index = %v, want 1(文本消息占 0)", fcAdded["output_index"]) + } + + // arguments delta / done + argsDelta := eventData(t, events[9]) + if argsDelta["delta"] != `{"city":"sh"}` || argsDelta["item_id"] != "call_1" { + t.Errorf("arguments delta = %v", argsDelta) + } + argsDone := eventData(t, events[10]) + if argsDone["arguments"] != `{"city":"sh"}` { + t.Errorf("arguments done = %v", argsDone) + } + fcDone := eventData(t, events[11]) + fcDoneItem, _ := fcDone["item"].(map[string]any) + if fcDoneItem["status"] != "completed" || fcDoneItem["arguments"] != `{"city":"sh"}` || fcDoneItem["name"] != "get_weather" { + t.Errorf("function_call done item = %v", fcDoneItem) + } + + // response.completed:output 含消息 + 工具调用,usage 数值与明细透传 + completed := eventData(t, events[12]) + respDone, _ := completed["response"].(map[string]any) + if respDone["status"] != "completed" { + t.Errorf("completed status = %v", respDone["status"]) + } + output, _ := respDone["output"].([]map[string]any) + if len(output) != 2 { + t.Fatalf("completed output = %v, want message + function_call", respDone["output"]) + } + usageObj, _ := respDone["usage"].(map[string]any) + if usageObj["input_tokens"] != 7 || usageObj["output_tokens"] != 3 || usageObj["total_tokens"] != 10 { + t.Errorf("usage map = %v", usageObj) + } + inDetails, _ := usageObj["input_tokens_details"].(map[string]any) + if inDetails["cached_tokens"] != 4 { + t.Errorf("input_tokens_details = %v(缓存明细不能丢)", inDetails) + } + outDetails, _ := usageObj["output_tokens_details"].(map[string]any) + if outDetails["reasoning_tokens"] != 2 { + t.Errorf("output_tokens_details = %v", outDetails) + } + + // StreamEvent.Usage 只挂在 completed 事件上,且带明细 + for i, ev := range events[:len(events)-1] { + if ev.Usage != nil { + t.Errorf("event[%d] %s should not carry usage", i, ev.Event) + } + } + final := events[len(events)-1] + if final.Usage == nil || final.Usage.PromptTokens != 7 || final.Usage.CompletionTokens != 3 || final.Usage.TotalTokens != 10 { + t.Fatalf("StreamEvent.Usage = %+v", final.Usage) + } + if final.Usage.PromptTokensDetails == nil || final.Usage.PromptTokensDetails.CachedTokens != 4 { + t.Errorf("StreamEvent.Usage prompt details = %+v", final.Usage.PromptTokensDetails) + } + if final.Usage.CompletionTokenDetails == nil || final.Usage.CompletionTokenDetails.ReasoningTokens != 2 { + t.Errorf("StreamEvent.Usage completion details = %+v", final.Usage.CompletionTokenDetails) + } +} + +// TestOpenAIToResponsesStream_TextOnlyEstimatedUsage 纯文本流、上游无 usage: +// finish_reason 触发文本收尾事件,completed usage 按文本长度估算(4 字符/token)。 +func TestOpenAIToResponsesStream_TextOnlyEstimatedUsage(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"12345678"}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + events, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + names := eventNames(events) + want := []string{ + "response.created", "response.output_item.added", "response.content_part.added", + "response.output_text.delta", + "response.output_text.done", "response.content_part.done", "response.output_item.done", + "response.completed", + } + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("events = %v, want %v", names, want) + } + final := events[len(events)-1] + if final.Usage == nil || final.Usage.CompletionTokens != 2 || final.Usage.TotalTokens != 2 { + t.Errorf("estimated usage = %+v, want completion=2 (8 字符 / 4)", final.Usage) + } +} + +// TestOpenAIToResponsesStream_ReasoningDelta chat reasoning_content → +// response.reasoning_summary_text.delta。 +func TestOpenAIToResponsesStream_ReasoningDelta(t *testing.T) { + sse := strings.Join([]string{ + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"mull"}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi"}}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + events, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + var found bool + for _, ev := range events { + if ev.Event == "response.reasoning_summary_text.delta" { + data := eventData(t, ev) + if data["delta"] != "mull" || data["summary_index"] != 0 { + t.Errorf("reasoning delta = %v", data) + } + found = true + } + } + if !found { + t.Error("reasoning_summary_text.delta not found") + } +} + +// TestOpenAIToResponsesStream_EchoFromStash 已 stash 的请求快照 echo 进 response 对象。 +func TestOpenAIToResponsesStream_EchoFromStash(t *testing.T) { + info := newStashMeta("gpt-4o", "", false) + temp := 0.3 + topP := 0.9 + maxOut := uint(512) + info.stashed = &dto.OpenAIResponsesRequest{ + Temperature: &temp, + TopP: &topP, + MaxOutputTokens: &maxOut, + Instructions: []byte(`"be nice"`), + } + sse := strings.Join([]string{ + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"}}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + events, err := runChatToResponsesStream(t, info, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + created := eventData(t, events[0]) + respObj, _ := created["response"].(map[string]any) + if respObj["temperature"] != 0.3 || respObj["top_p"] != 0.9 { + t.Errorf("echo temperature/top_p = %v/%v", respObj["temperature"], respObj["top_p"]) + } + if mo, _ := respObj["max_output_tokens"].(*int); mo == nil || *mo != 512 { + t.Errorf("echo max_output_tokens = %v", respObj["max_output_tokens"]) + } +} + +// TestOpenAIToResponsesStream_EmbeddedError SSE 中内嵌 {"error":{...}} 时终止并报错 +// (错误透传由宿主负责)。 +func TestOpenAIToResponsesStream_EmbeddedError(t *testing.T) { + sse := strings.Join([]string{ + `data: {"error":{"message":"quota exceeded","type":"insufficient_quota"}}`, + ``, + }, "\n") + events, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err == nil { + t.Fatal("embedded error should terminate the stream with error") + } + if !strings.Contains(err.Error(), "quota exceeded") { + t.Errorf("error = %v", err) + } + if len(events) != 0 { + t.Errorf("no event should be emitted before embedded error, got %v", eventNames(events)) + } +} + +// TestOpenAIToResponsesStream_NonChatFormatGuard 上游流不是 chat 格式(无 choices、 +// 无内容、无 usage)时不得合成假成功的空 completed,必须报错。 +func TestOpenAIToResponsesStream_NonChatFormatGuard(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.output_text.delta","delta":"leaked responses sse"}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + _, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err == nil { + t.Fatal("non-chat stream should be rejected") + } + if !strings.Contains(err.Error(), "not chat completions format") { + t.Errorf("error = %v", err) + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go new file mode 100644 index 0000000..cdb8ec0 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go @@ -0,0 +1,473 @@ +// Package oai_responses 实现 OpenAI Responses API ↔ Chat Completions 双向协议转换器。 +// +// 从宿主 relay/channel/openai 的 converter.go / responses.go 移植纯转换逻辑: +// - 请求:Responses → chat(ResponsesToOpenAIRequestConverter)、 +// chat → Responses(OpenAIToResponsesRequestConverter) +// - 响应:Responses 上游 → chat 客户端(非流式 + 流式)、 +// chat 上游 → Responses 客户端(非流式 + 流式) +// +// 宿主侧职责(HTTP 写出、错误透传、SSE 帧化、[DONE]、流中断计费兜底、响应路由记录) +// 不在本包,由宿主桥接层处理。 +package oai_responses + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// ResponsesToOpenAIRequestConverter 将 Responses API 请求转换为 Chat Completions 请求。 +type ResponsesToOpenAIRequestConverter struct{} + +func (c *ResponsesToOpenAIRequestConverter) ID() string { + return relayconvert.ConverterOpenAIResponsesToOpenAIChat +} + +func (c *ResponsesToOpenAIRequestConverter) From() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *ResponsesToOpenAIRequestConverter) To() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *ResponsesToOpenAIRequestConverter) Quality() relayconvert.RequestConverterQuality { + return relayconvert.RequestConverterQualityFair +} + +// ConvertRequest 将 Responses API 请求转换为 Chat Completions 格式。 +func (c *ResponsesToOpenAIRequestConverter) ConvertRequest( + ctx context.Context, + info convmeta.Meta, + request any, +) (any, error) { + req, ok := request.(*dto.OpenAIResponsesRequest) + if !ok { + return nil, fmt.Errorf("expected *dto.OpenAIResponsesRequest, got %T", request) + } + + // 有状态请求快速失败:previous_response_id 的会话历史存储在上游 Responses 服务侧, + // chat-only 渠道无法还原(网关不存储响应体),降级转换会静默丢失全部上下文。 + // 返回哨兵错误,由宿主按渠道级致命上报调度 FSM 换渠道。 + if req.PreviousResponseID != "" { + return nil, fmt.Errorf("stateful responses (previous_response_id) not supported by chat-only channels: %w", relayconvert.ErrStatefulResponsesUnsupported) + } + + // stash 请求快照,供上游 chat 响应合成回 Responses 格式时 echo 请求参数 + // (能力接口:宿主未实现时静默跳过) + if stash, ok := info.(convmeta.ResponsesStash); ok { + stash.StashResponsesRequest(req) + } + + chatReq := &dto.GeneralOpenAIRequest{Model: req.Model} + // 模型名:优先映射后的上游模型名,否则用客户端请求模型名 + if upstream := convmeta.UpstreamModelName(info); upstream != "" { + chatReq.Model = upstream + } + + messages := make([]dto.Message, 0) + if len(req.Instructions) > 0 { + var instructions string + if err := json.Unmarshal(req.Instructions, &instructions); err == nil && instructions != "" { + messages = append(messages, dto.Message{Role: "system", Content: instructions}) + } + } + inputMessages, err := r2cConvertInputToMessages(req.Input) + if err != nil { + return nil, fmt.Errorf("convert input to messages: %w", err) + } + messages = append(messages, inputMessages...) + chatReq.Messages = messages + + if req.Stream != nil { + chatReq.Stream = req.Stream + if *req.Stream { + chatReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true} + } + } + chatReq.Temperature = req.Temperature + chatReq.TopP = req.TopP + if req.MaxOutputTokens != nil { + maxTokens := int(*req.MaxOutputTokens) + chatReq.MaxTokens = &maxTokens + } + if req.Logprobs != nil { + logprobs := true + chatReq.LogProbs = &logprobs + chatReq.TopLogProbs = req.Logprobs + } else if req.TopLogProbs != nil { + logprobs := true + chatReq.TopLogProbs = req.TopLogProbs + chatReq.LogProbs = &logprobs + } + if len(req.Tools) > 0 { + if chatTools := r2cConvertTools(req.Tools); len(chatTools) > 0 { + chatReq.Tools = chatTools + } + + // 服务端联网搜索:Responses 的 web_search 工具 → chat 的 web_search_options。 + // chat 是跨原生方向的转换中枢(Responses→OpenAI→Claude/Gemini 两跳链), + // 这里不承载就等于整条链上的搜索能力全部丢失。 + // + // 注意:web_search_options 同时是 Responses→Claude 链的**中间载体**(第二跳 + // OpenAI→Claude 据此还原 web_search 工具),此处不得按模型名做任何裁剪; + // 「chat 终端 + claude 系模型」的剥离在 openai 适配器出口做(aggregator 场景, + // 聚合器会把它映射成无法表达的原生搜索工具,导致空响应)。 + if spec := shared.DetectWebSearchFromResponsesTools(req.Tools); spec != nil { + chatReq.WebSearchOptions = spec.ToOpenAIOptions() + } + } + if len(req.ToolChoice) > 0 { + chatReq.ToolChoice = r2cConvertToolChoice(req.ToolChoice) + } + if req.Reasoning != nil && req.Reasoning.Effort != "" { + chatReq.ReasoningEffort = req.Reasoning.Effort + } + if req.ServiceTier != "" { + chatReq.ServiceTier = req.ServiceTier + } + if req.PromptCacheKey != "" { + chatReq.PromptCacheKey = req.PromptCacheKey + } + if len(req.Text) > 0 { + if rf := r2cParseTextFormat(req.Text); rf != nil { + chatReq.ResponseFormat = rf + } + } + if len(req.FrequencyPenalty) > 0 { + var v float64 + if err := json.Unmarshal(req.FrequencyPenalty, &v); err == nil { + chatReq.FrequencyPenalty = &v + } + } + if len(req.PresencePenalty) > 0 { + var v float64 + if err := json.Unmarshal(req.PresencePenalty, &v); err == nil { + chatReq.PresencePenalty = &v + } + } + if len(req.Metadata) > 0 { + chatReq.Metadata = req.Metadata + } + + return chatReq, nil +} + +type r2cInputItem struct { + Type string `json:"type"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` + CallID string `json:"call_id,omitempty"` + Output string `json:"output,omitempty"` + Text string `json:"text,omitempty"` + // function_call 项字段(Responses 历史中的助手工具调用) + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + // reasoning 项的思考文本(OpenAI 官方形态在 summary[],部分聚合器直连输出放 content[]) + Summary []r2cTextPart `json:"summary,omitempty"` +} + +// r2cTextPart Responses 输出项中文本部件的最小读取形态。 +type r2cTextPart struct { + Text string `json:"text"` +} + +// r2cReasoningText 提取 reasoning 项的思考文本,summary 与 content 两种形态按序拼接。 +func r2cReasoningText(item r2cInputItem) string { + var parts []string + for _, s := range item.Summary { + if s.Text != "" { + parts = append(parts, s.Text) + } + } + if len(item.Content) > 0 { + var contentParts []r2cTextPart + if err := json.Unmarshal(item.Content, &contentParts); err == nil { + for _, c := range contentParts { + if c.Text != "" { + parts = append(parts, c.Text) + } + } + } + } + return strings.Join(parts, "\n") +} + +type r2cContentPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` + URL string `json:"url,omitempty"` + Detail string `json:"detail,omitempty"` + // input_audio:Responses 为 {"type":"input_audio","input_audio":{"data","format"}}, + // chat 同形,原样透传 + InputAudio *r2cInputAudio `json:"input_audio,omitempty"` + // input_file:Responses 为扁平 {"type":"input_file","file_data","filename"}, + // 转换为 chat 的 {"type":"file","file":{"file_data","filename"}} + FileData string `json:"file_data,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type r2cInputAudio struct { + Data string `json:"data,omitempty"` + Format string `json:"format,omitempty"` +} + +func r2cConvertInputToMessages(input json.RawMessage) ([]dto.Message, error) { + if len(input) == 0 { + return nil, nil + } + var simpleText string + if err := json.Unmarshal(input, &simpleText); err == nil { + return []dto.Message{{Role: "user", Content: simpleText}}, nil + } + var items []json.RawMessage + if err := json.Unmarshal(input, &items); err != nil { + return nil, fmt.Errorf("input must be string or array: %w", err) + } + messages := make([]dto.Message, 0, len(items)) + // 连续的 function_call 项聚合为一条 assistant 消息(chat 协议的 tool_calls 数组语义), + // 其后的 function_call_output 转为引用对应 tool_call_id 的 tool 消息 + var pendingToolCalls []dto.ToolCall + // reasoning 项的思考文本:挂到**下一条** assistant 消息(Responses 的 reasoning 项 + // 总是排在对应的 message / function_call 之前)。DeepSeek 等推理上游的 thinking 模式 + // 要求多轮历史把 reasoning_content 传回,丢弃会导致 400: + // "The `reasoning_content` in the thinking mode must be passed back to the API." + // 仅当历史携带可读思考文本(明文 summary/content)时才会产出该字段—— + // 上游若从不返回 reasoning_content(如 OpenAI 官方 chat),历史里就没有这类项,天然不触发。 + var pendingReasoning string + takeReasoning := func() *string { + if pendingReasoning == "" { + return nil + } + r := pendingReasoning + pendingReasoning = "" + return &r + } + flushToolCalls := func() { + if len(pendingToolCalls) == 0 { + return + } + messages = append(messages, dto.Message{ + Role: "assistant", + Content: nil, + ToolCalls: pendingToolCalls, + ReasoningContent: takeReasoning(), + }) + pendingToolCalls = nil + } + for _, raw := range items { + var item r2cInputItem + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + switch item.Type { + case "message": + flushToolCalls() + if msg := r2cConvertMessage(item); msg != nil { + if msg.Role == "assistant" { + msg.ReasoningContent = takeReasoning() + } else { + pendingReasoning = "" // user 消息前的 reasoning 无人可挂,丢弃 + } + messages = append(messages, *msg) + } + case "function_call": + // 历史中的助手工具调用:转为 assistant.tool_calls 条目,id 用 call_id(与 tool 消息的 tool_call_id 对应) + if item.CallID == "" && item.Name == "" { + continue + } + pendingToolCalls = append(pendingToolCalls, dto.ToolCall{ + ID: item.CallID, + Type: "function", + Function: dto.FunctionCall{ + Name: item.Name, + Arguments: item.Arguments, + }, + }) + case "function_call_output": + flushToolCalls() + messages = append(messages, dto.Message{Role: "tool", ToolCallID: item.CallID, Content: item.Output}) + case "reasoning": + pendingReasoning = r2cReasoningText(item) + default: + flushToolCalls() + if item.Role != "" { + if msg := r2cConvertMessage(item); msg != nil { + if msg.Role == "assistant" { + msg.ReasoningContent = takeReasoning() + } + messages = append(messages, *msg) + } + } + } + } + flushToolCalls() + return messages, nil +} + +func r2cConvertMessage(item r2cInputItem) *dto.Message { + role := item.Role + if role == "" { + role = "user" + } + // Responses 的 developer 角色(OpenAI 新式系统提示,codex 等客户端常用) + // 多数第三方 chat 上游不识别(serde 严格校验直接拒绝),统一映射为 system + if role == "developer" { + role = "system" + } + if len(item.Content) == 0 { + return nil + } + var textContent string + if err := json.Unmarshal(item.Content, &textContent); err == nil { + return &dto.Message{Role: role, Content: textContent} + } + var parts []r2cContentPart + if err := json.Unmarshal(item.Content, &parts); err != nil { + return nil + } + // 产出类型化 []dto.ContentPart(链内规范形态):本方向常作为步骤链首跳 + // (Responses→OpenAI→Claude/Gemini),第二跳转换器按 NormalizeContentParts + // 归一化处理类型化列表;map 切片等私有形态会导致下游多模态静默丢失。 + chatParts := make([]dto.ContentPart, 0, len(parts)) + for _, part := range parts { + switch part.Type { + case "input_text": + chatParts = append(chatParts, dto.ContentPart{Type: "text", Text: part.Text}) + case "input_audio": + // 音频输入:chat 的 input_audio 与 Responses 同形(input_audio:{data,format}) + if part.InputAudio != nil && part.InputAudio.Data != "" { + chatParts = append(chatParts, dto.ContentPart{Type: "input_audio", InputAudio: &dto.InputAudio{ + Data: part.InputAudio.Data, + Format: part.InputAudio.Format, + }}) + } + case "input_file": + // 文件输入:Responses 扁平 {file_data,filename} → chat 的 file:{file_data,filename} + if part.FileData != "" { + chatParts = append(chatParts, dto.ContentPart{Type: "file", File: &dto.FileData{ + FileData: part.FileData, + Filename: part.Filename, + }}) + } + case "input_image": + imageURL := part.ImageURL + if imageURL == "" { + imageURL = part.URL + } + if imageURL != "" { + chatParts = append(chatParts, dto.ContentPart{Type: "image_url", ImageURL: &dto.ImageURL{ + URL: imageURL, + Detail: part.Detail, + }}) + } + case "output_text": + chatParts = append(chatParts, dto.ContentPart{Type: "text", Text: part.Text}) + } + } + if len(chatParts) == 0 { + return nil + } + if len(chatParts) == 1 && chatParts[0].Type == "text" { + return &dto.Message{Role: role, Content: chatParts[0].Text} + } + return &dto.Message{Role: role, Content: chatParts} +} + +func r2cConvertTools(toolsRaw json.RawMessage) []dto.Tool { + var tools []map[string]any + if err := json.Unmarshal(toolsRaw, &tools); err != nil { + return nil + } + chatTools := make([]dto.Tool, 0, len(tools)) + for _, tool := range tools { + toolType, _ := tool["type"].(string) + if toolType == "function" { + name, _ := tool["name"].(string) + description, _ := tool["description"].(string) + chatTools = append(chatTools, dto.Tool{ + Type: "function", + Function: dto.FunctionDef{ + Name: name, + Description: description, + Parameters: tool["parameters"], + }, + }) + } + } + return chatTools +} + +func r2cConvertToolChoice(tcRaw json.RawMessage) any { + if len(tcRaw) == 0 { + return "auto" + } + var strVal string + if err := json.Unmarshal(tcRaw, &strVal); err == nil { + return strVal + } + var tc map[string]any + if err := json.Unmarshal(tcRaw, &tc); err != nil { + return "auto" + } + if tc["type"] == "function" { + // chat 嵌套形状 {"type":"function","function":{"name":...}}(防御性兼容) + if fn, ok := tc["function"].(map[string]any); ok { + if name, ok := fn["name"].(string); ok { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + // Responses wire 扁平形状 {"type":"function","name":...}——必须重组为 chat 嵌套形状, + // 否则强制工具选择在 chat 上游被静默忽略(能力守恒测试守护) + if name, ok := tc["name"].(string); ok && name != "" { + return map[string]any{"type": "function", "function": map[string]any{"name": name}} + } + } + return tc +} + +// r2cParseTextFormat 解析 Responses text.format(扁平 {type,name,schema,strict}) +// 为 chat 的 response_format(json_schema 时嵌套为 json_schema:{name,schema,strict})。 +// text 或未知类型返回 nil(chat 无对应字段,不映射)。 +func r2cParseTextFormat(raw json.RawMessage) *dto.ResponseFormat { + var textCfg struct { + Format struct { + Type string `json:"type"` + Name string `json:"name"` + Schema json.RawMessage `json:"schema"` + Strict *bool `json:"strict"` + } `json:"format"` + } + if err := json.Unmarshal(raw, &textCfg); err != nil { + return nil + } + switch textCfg.Format.Type { + case "json_object": + return &dto.ResponseFormat{Type: "json_object"} + case "json_schema": + jsonSchema := make(map[string]any, 3) + if textCfg.Format.Name != "" { + jsonSchema["name"] = textCfg.Format.Name + } + if len(textCfg.Format.Schema) > 0 { + var schema any + if err := json.Unmarshal(textCfg.Format.Schema, &schema); err == nil { + jsonSchema["schema"] = schema + } + } + if textCfg.Format.Strict != nil { + jsonSchema["strict"] = *textCfg.Format.Strict + } + return &dto.ResponseFormat{Type: "json_schema", JSONSchema: jsonSchema} + default: + return nil + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go new file mode 100644 index 0000000..16a91d5 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go @@ -0,0 +1,399 @@ +package oai_responses + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// convertResponsesBody 解析 Responses 请求 JSON、执行转换并把结果 chat 请求重新序列化为 map +// (对齐宿主 ConvertResponsesToOpenAI 测试的字节级断言口径)。 +func convertResponsesBody(t *testing.T, info convmeta.Meta, body string) map[string]any { + t.Helper() + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("parse responses request: %v", err) + } + conv := &ResponsesToOpenAIRequestConverter{} + out, err := conv.ConvertRequest(context.Background(), info, &req) + if err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + raw, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal chat request: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("bad json: %v\n%s", err, raw) + } + return m +} + +func TestResponsesToOpenAIRequest_WrongType(t *testing.T) { + conv := &ResponsesToOpenAIRequestConverter{} + if _, err := conv.ConvertRequest(context.Background(), &convmeta.Values{}, "not a request"); err == nil { + t.Fatal("wrong input type should be rejected") + } +} + +// TestResponsesToOpenAIRequest_PenaltyPassthrough Responses → chat 时 penalty 原样透传。 +func TestResponsesToOpenAIRequest_PenaltyPassthrough(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":"hi","frequency_penalty":0.5,"presence_penalty":0.2}`) + if m["frequency_penalty"] != 0.5 { + t.Errorf("frequency_penalty = %v, want 0.5", m["frequency_penalty"]) + } + if m["presence_penalty"] != 0.2 { + t.Errorf("presence_penalty = %v, want 0.2", m["presence_penalty"]) + } + if _, ok := m["messages"]; !ok { + t.Error("messages should be present") + } +} + +// TestResponsesToOpenAIRequest_PreviousResponseIDRejected 有状态请求(previous_response_id) +// 落在 chat-only 渠道时必须快速失败:降级转换会静默丢失全部会话上下文。 +// 错误须携带哨兵以供宿主识别并驱动调度 FSM 换渠道。 +func TestResponsesToOpenAIRequest_PreviousResponseIDRejected(t *testing.T) { + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"gpt-4o","input":"hi","previous_response_id":"resp_prev"}`), &req); err != nil { + t.Fatalf("parse: %v", err) + } + conv := &ResponsesToOpenAIRequestConverter{} + _, err := conv.ConvertRequest(context.Background(), &convmeta.Values{}, &req) + if err == nil { + t.Fatal("previous_response_id should be rejected on chat-only conversion") + } + if !errors.Is(err, relayconvert.ErrStatefulResponsesUnsupported) { + t.Errorf("error should wrap ErrStatefulResponsesUnsupported, got: %v", err) + } +} + +// TestResponsesToOpenAIRequest_TextFormat Responses text.format(扁平结构)转 chat +// response_format(json_schema 嵌套结构);text 类型不映射。 +func TestResponsesToOpenAIRequest_TextFormat(t *testing.T) { + cases := []struct { + name string + text string + want map[string]any + }{ + { + name: "json_object", + text: `{"format":{"type":"json_object"}}`, + want: map[string]any{"type": "json_object"}, + }, + { + name: "json_schema", + text: `{"format":{"type":"json_schema","name":"out","schema":{"type":"object"},"strict":true}}`, + want: map[string]any{ + "type": "json_schema", + "json_schema": map[string]any{ + "name": "out", + "schema": map[string]any{"type": "object"}, + "strict": true, + }, + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":"hi","text":`+c.text+`}`) + got, ok := m["response_format"].(map[string]any) + if !ok { + t.Fatalf("response_format missing or wrong type: %v", m["response_format"]) + } + wantJSON, _ := json.Marshal(c.want) + gotJSON, _ := json.Marshal(got) + if string(gotJSON) != string(wantJSON) { + t.Errorf("response_format = %s, want %s", gotJSON, wantJSON) + } + }) + } + + t.Run("text format not mapped", func(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":"hi","text":{"format":{"type":"text"}}}`) + if _, ok := m["response_format"]; ok { + t.Error("text format should not map to response_format") + } + }) +} + +// TestResponsesToOpenAIRequest_StashesResponsesRequest 转换时经 ResponsesStash 能力接口 +// stash 请求快照,供上游 chat 响应合成回 Responses 格式时 echo 请求参数。 +func TestResponsesToOpenAIRequest_StashesResponsesRequest(t *testing.T) { + info := newStashMeta("gpt-4o", "", false) + // 先填充再转换,断言转换覆盖为最新请求体 + info.stashed = &dto.OpenAIResponsesRequest{Model: "stale"} + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"gpt-4o","input":"hi","temperature":0.7}`), &req); err != nil { + t.Fatalf("parse: %v", err) + } + conv := &ResponsesToOpenAIRequestConverter{} + if _, err := conv.ConvertRequest(context.Background(), info, &req); err != nil { + t.Fatalf("ConvertRequest: %v", err) + } + if info.stashed == nil { + t.Fatal("ResponsesRequest should be stashed") + } + if info.stashed.Model != "gpt-4o" { + t.Errorf("stashed model = %q, want gpt-4o", info.stashed.Model) + } + if info.stashed.Temperature == nil || *info.stashed.Temperature != 0.7 { + t.Errorf("stashed temperature = %v, want 0.7", info.stashed.Temperature) + } +} + +// TestResponsesToOpenAIRequest_NoStashCapability info 未实现 ResponsesStash 时静默跳过。 +func TestResponsesToOpenAIRequest_NoStashCapability(t *testing.T) { + var req dto.OpenAIResponsesRequest + if err := json.Unmarshal([]byte(`{"model":"gpt-4o","input":"hi"}`), &req); err != nil { + t.Fatalf("parse: %v", err) + } + conv := &ResponsesToOpenAIRequestConverter{} + if _, err := conv.ConvertRequest(context.Background(), &convmeta.Values{}, &req); err != nil { + t.Fatalf("ConvertRequest without stash capability: %v", err) + } +} + +// TestResponsesToOpenAIRequest_InputAudioAndFile 多模态输入透传: +// input_audio 与 Responses 同形透传;input_file 扁平结构转 chat 的 file 嵌套结构。 +func TestResponsesToOpenAIRequest_InputAudioAndFile(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":[{"type":"message","role":"user","content":[`+ + `{"type":"input_text","text":"listen"},`+ + `{"type":"input_audio","input_audio":{"data":"QUJD","format":"wav"}},`+ + `{"type":"input_file","file_data":"data:text/plain;base64,aGk=","filename":"a.txt"}`+ + `]}]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 1 { + t.Fatalf("messages = %v", msgs) + } + parts, _ := msgs[0].(map[string]any)["content"].([]any) + if len(parts) != 3 { + t.Fatalf("content parts = %v, want 3(audio/file 不再被丢弃)", parts) + } + audio, _ := parts[1].(map[string]any) + if audio["type"] != "input_audio" { + t.Errorf("audio part = %v", audio) + } + ia, _ := audio["input_audio"].(map[string]any) + if ia["data"] != "QUJD" || ia["format"] != "wav" { + t.Errorf("input_audio = %v", ia) + } + file, _ := parts[2].(map[string]any) + if file["type"] != "file" { + t.Errorf("file part = %v", file) + } + f, _ := file["file"].(map[string]any) + if f["file_data"] != "data:text/plain;base64,aGk=" || f["filename"] != "a.txt" { + t.Errorf("file = %v", f) + } +} + +// TestResponsesToOpenAIRequest_DeveloperRoleMappedToSystem codex 等客户端会在 input 中 +// 发送 developer 角色消息(新式系统提示);第三方 chat 上游(serde 严格校验)不识别该角色, +// 必须映射为 system,否则上游直接拒绝整个请求体。 +func TestResponsesToOpenAIRequest_DeveloperRoleMappedToSystem(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-flash","instructions":"You are helpful.",`+ + `"input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are a coding agent."}]},`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"stream":true}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %v, want 3 (instructions→system + developer→system + user)", msgs) + } + for i, want := range []string{"system", "system", "user"} { + msg := msgs[i].(map[string]any) + if got := msg["role"]; got != want { + t.Errorf("messages[%d].role = %v, want %q", i, got, want) + } + } + // stream:true 必须注入 stream_options.include_usage 保证流末 usage 可计费 + if m["stream"] != true { + t.Errorf("stream = %v, want true", m["stream"]) + } + so, _ := m["stream_options"].(map[string]any) + if so == nil || so["include_usage"] != true { + t.Errorf("stream_options = %v, want include_usage=true", m["stream_options"]) + } +} + +// TestResponsesToOpenAIRequest_FunctionCallHistory codex 多轮会把历史 function_call / +// function_call_output / reasoning 项放进 input:function_call 需聚合为 assistant.tool_calls +// (其后的 tool 消息才有对应的 tool_call_id),reasoning 项直接跳过。 +func TestResponsesToOpenAIRequest_FunctionCallHistory(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"list files"}]},`+ + `{"type":"reasoning","summary":[]},`+ + `{"type":"function_call","call_id":"call_a","name":"shell","arguments":"{\"cmd\":\"ls\"}"},`+ + `{"type":"function_call","call_id":"call_b","name":"read","arguments":"{\"path\":\"a.go\"}"},`+ + `{"type":"function_call_output","call_id":"call_a","output":"a.go b.go"},`+ + `{"type":"function_call_output","call_id":"call_b","output":"package main"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 4 { + t.Fatalf("messages = %v, want 4 (user + assistant[2 calls] + tool + tool)", msgs) + } + + // 连续两条 function_call 聚合为一条 assistant 消息 + assistant := msgs[1].(map[string]any) + if assistant["role"] != "assistant" { + t.Fatalf("messages[1].role = %v, want assistant", assistant["role"]) + } + calls, _ := assistant["tool_calls"].([]any) + if len(calls) != 2 { + t.Fatalf("tool_calls = %v, want 2 aggregated calls", calls) + } + first, _ := calls[0].(map[string]any) + if first["id"] != "call_a" || first["type"] != "function" { + t.Errorf("tool_calls[0] = %v", first) + } + fn, _ := first["function"].(map[string]any) + if fn["name"] != "shell" || fn["arguments"] != `{"cmd":"ls"}` { + t.Errorf("tool_calls[0].function = %v", fn) + } + + // function_call_output → tool 消息,tool_call_id 与聚合条目对应 + for i, wantID := range []string{"call_a", "call_b"} { + tool := msgs[2+i].(map[string]any) + if tool["role"] != "tool" || tool["tool_call_id"] != wantID { + t.Errorf("messages[%d] = %v, want tool with tool_call_id=%s", 2+i, tool, wantID) + } + } +} + +// TestResponsesToOpenAIRequest_ModelMapping 已附加渠道信息时使用映射后的上游模型名。 +func TestResponsesToOpenAIRequest_ModelMapping(t *testing.T) { + info := &convmeta.Values{OriginModelName: "gpt-4o", UpstreamModelName: "gpt-4o-upstream", ChannelMetaAttached: true} + m := convertResponsesBody(t, info, `{"model":"gpt-4o","input":"hi"}`) + if m["model"] != "gpt-4o-upstream" { + t.Errorf("model = %v, want gpt-4o-upstream", m["model"]) + } + // 无渠道信息:用客户端请求模型名 + m = convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":"hi"}`) + if m["model"] != "gpt-4o" { + t.Errorf("model = %v, want gpt-4o", m["model"]) + } +} + +// TestResponsesToOpenAIRequest_ParamMapping 常规参数映射:温度/最大 token/logprobs/工具/工具选择。 +func TestResponsesToOpenAIRequest_ParamMapping(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-4o","input":"hi",`+ + `"temperature":0.4,"top_p":0.8,"max_output_tokens":256,"logprobs":3,`+ + `"reasoning":{"effort":"high"},"service_tier":"flex","prompt_cache_key":"pk",`+ + `"tools":[{"type":"function","name":"get_weather","description":"d","parameters":{"type":"object"}},{"type":"web_search"}],`+ + `"tool_choice":{"type":"function","function":{"name":"get_weather"}},"metadata":{"k":"v"}}`) + if m["temperature"] != 0.4 || m["top_p"] != 0.8 { + t.Errorf("temperature/top_p = %v/%v", m["temperature"], m["top_p"]) + } + if m["max_tokens"] != float64(256) { + t.Errorf("max_tokens = %v, want 256", m["max_tokens"]) + } + if m["logprobs"] != true || m["top_logprobs"] != float64(3) { + t.Errorf("logprobs = %v, top_logprobs = %v", m["logprobs"], m["top_logprobs"]) + } + if m["reasoning_effort"] != "high" { + t.Errorf("reasoning_effort = %v, want high", m["reasoning_effort"]) + } + if m["service_tier"] != "flex" || m["prompt_cache_key"] != "pk" { + t.Errorf("service_tier/prompt_cache_key = %v/%v", m["service_tier"], m["prompt_cache_key"]) + } + tools, _ := m["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools = %v, want 1 (仅 function 类型保留)", tools) + } + fn, _ := tools[0].(map[string]any)["function"].(map[string]any) + if fn["name"] != "get_weather" { + t.Errorf("tools[0].function = %v", fn) + } + tc, _ := m["tool_choice"].(map[string]any) + tcFn, _ := tc["function"].(map[string]any) + if tc["type"] != "function" || tcFn["name"] != "get_weather" { + t.Errorf("tool_choice = %v", tc) + } + md, _ := m["metadata"].(map[string]any) + if md["k"] != "v" { + t.Errorf("metadata = %v", m["metadata"]) + } +} + +// TestResponsesToOpenAIRequest_ReasoningPassedBackAsReasoningContent 思考内容回传: +// DeepSeek 等推理上游的 thinking 模式要求多轮历史携带 reasoning_content, +// 丢弃 reasoning 项会导致上游 400(线上真实报错: +// "The `reasoning_content` in the thinking mode must be passed back to the API.")。 +// 覆盖 summary(OpenAI 官方形态)与 content(部分聚合器直连形态)两种携带方式, +// 以及挂到文本消息 / 聚合到 tool_calls 消息两条路径。 +func TestResponsesToOpenAIRequest_ReasoningPassedBackAsReasoningContent(t *testing.T) { + t.Run("summary 形态挂到文本消息", func(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-pro","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},`+ + `{"type":"reasoning","summary":[{"type":"summary_text","text":"思考A"}]},`+ + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"答案"}]},`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"and?"}]}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %v, want 3", msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["reasoning_content"] != "思考A" { + t.Errorf("assistant.reasoning_content = %v, want 思考A", assistant["reasoning_content"]) + } + // user 消息不受影响 + if _, ok := msgs[0].(map[string]any)["reasoning_content"]; ok { + t.Error("user 消息不应携带 reasoning_content") + } + }) + + t.Run("content 形态挂到 tool_calls 聚合消息", func(t *testing.T) { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-pro","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"ls"}]},`+ + `{"type":"reasoning","content":[{"type":"reasoning_text","text":"先用shell"}]},`+ + `{"type":"function_call","call_id":"call_a","name":"shell","arguments":"{\"cmd\":\"ls\"}"},`+ + `{"type":"function_call_output","call_id":"call_a","output":"a.go"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %v, want 3 (user + assistant[tool_calls] + tool)", msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["reasoning_content"] != "先用shell" { + t.Errorf("assistant.reasoning_content = %v, want 先用shell", assistant["reasoning_content"]) + } + if _, ok := assistant["tool_calls"]; !ok { + t.Error("reasoning 挂载不应破坏 tool_calls 聚合") + } + }) + + t.Run("加密无明文不产出字段", func(t *testing.T) { + // OpenAI 官方加密 reasoning(仅 encrypted_content)无可读文本, + // 不应产出空 reasoning_content(部分严格 serde 上游会拒绝空值字段) + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"gpt-5.6","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},`+ + `{"type":"reasoning","encrypted_content":"gAAAAA"},`+ + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}`+ + `]}`) + msgs, _ := m["messages"].([]any) + assistant := msgs[1].(map[string]any) + if _, ok := assistant["reasoning_content"]; ok { + t.Errorf("无可读思考文本时不应携带 reasoning_content: %v", assistant) + } + }) +} + +// TestResponsesToOpenAIRequest_WebSearchOptionsAlwaysEmitted web_search_options 在本层 +// 必须无条件产出:它同时是 Responses→OpenAI→Claude 链的**中间载体**(第二跳据此还原 +// web_search 工具),按模型名裁剪会掐断 claude 原生渠道的搜索能力。 +// 「chat 终端 + claude 系模型」的剥离在 openai 适配器出口做(聚合器场景), +// 见 relay/channel/openai/adaptor.go 的 stripWebSearchOptionsForClaudeModels。 +func TestResponsesToOpenAIRequest_WebSearchOptionsAlwaysEmitted(t *testing.T) { + for _, model := range []string{"claude-sonnet-5", "gpt-4o-search-preview"} { + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"`+model+`","input":"hi","tools":[{"type":"web_search"}]}`) + if _, ok := m["web_search_options"]; !ok { + t.Errorf("%s: web_search_options 应作为链路中间载体无条件产出", model) + } + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response.go new file mode 100644 index 0000000..b032f97 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response.go @@ -0,0 +1,156 @@ +package oai_responses + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" + "github.com/qianfree/team-api/relaykit/types" +) + +// ResponsesToOpenAIResponseConverter 将 Responses API 非流式响应转换为 Chat Completions 响应。 +type ResponsesToOpenAIResponseConverter struct{} + +func (c *ResponsesToOpenAIResponseConverter) ID() string { + return relayconvert.ResponseConverterOAIResponsesToOAIChat +} + +func (c *ResponsesToOpenAIResponseConverter) From() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *ResponsesToOpenAIResponseConverter) To() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *ResponsesToOpenAIResponseConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertResponse 将 Responses API 非流式响应转换为 Chat Completions 非流式响应。 +// usage 信息写入 ChatCompletionResponse.Usage 字段(宿主由此提取计费用量)。 +func (c *ResponsesToOpenAIResponseConverter) ConvertResponse( + ctx context.Context, + info convmeta.Meta, + response any, +) (any, error) { + resp, ok := response.(*dto.OpenAIResponsesResponse) + if !ok { + return nil, fmt.Errorf("expected *dto.OpenAIResponsesResponse, got %T", response) + } + + // 模型名:映射渠道回写客户端请求的模型名;未映射时优先用上游响应携带的模型名 + modelName := resp.Model + if modelName == "" { + modelName = convmeta.UpstreamModelName(info) + } + if isModelMapped(info) || modelName == "" { + if info != nil { + modelName = info.GetOriginModelName() + } + } + + chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) + return responsesResponseToChatCompletions(resp, chatID, modelName), nil +} + +// responsesResponseToChatCompletions 将 Responses API 非流式响应转换为 Chat Completions 响应 +// (移植宿主 ResponsesResponseToChatCompletions 的纯转换部分,usage 并入返回值的 Usage 字段)。 +func responsesResponseToChatCompletions(resp *dto.OpenAIResponsesResponse, id string, model string) *dto.ChatCompletionResponse { + text, toolCalls := extractOutputFromResponses(resp) + + finishReason := "stop" + content := "" + if len(toolCalls) > 0 && text == "" { + finishReason = "tool_calls" + } + if text != "" || len(toolCalls) == 0 { + content = text + } + + usage := &dto.UsageWithDetails{} + if resp.Usage != nil { + usage.PromptTokens = resp.Usage.InputTokens + usage.CompletionTokens = resp.Usage.OutputTokens + usage.TotalTokens = resp.Usage.TotalTokens + if usage.TotalTokens == 0 { + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + } + if resp.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: resp.Usage.InputTokensDetails.CachedTokens, + CacheWriteTokens: resp.Usage.InputTokensDetails.CacheWriteTokens, + AudioTokens: resp.Usage.InputTokensDetails.AudioTokens, + TextTokens: resp.Usage.InputTokensDetails.TextTokens, + ImageTokens: resp.Usage.InputTokensDetails.ImageTokens, + } + } + if resp.Usage.OutputTokenDetails != nil { + usage.CompletionTokenDetails = &dto.TokenDetails{ + ReasoningTokens: resp.Usage.OutputTokenDetails.ReasoningTokens, + AudioTokens: resp.Usage.OutputTokenDetails.AudioTokens, + TextTokens: resp.Usage.OutputTokenDetails.TextTokens, + AcceptedPredictionTokens: resp.Usage.OutputTokenDetails.AcceptedPredictionTokens, + RejectedPredictionTokens: resp.Usage.OutputTokenDetails.RejectedPredictionTokens, + } + } + } else if text != "" { + // 上游未返回 usage 时按文本长度估算(4 字符/token) + estimated := len(text) / 4 + usage.CompletionTokens = estimated + usage.TotalTokens = usage.PromptTokens + estimated + } + + // reasoning 输出项 → chat 的 reasoning_content。此前一律丢弃, + // 推理模型经本方向转换后客户端看不到任何思考过程。 + msg := dto.Message{Role: "assistant", Content: content, ToolCalls: toolCalls} + if thinking := shared.ExtractResponsesReasoning(resp.Output); thinking != "" { + msg.ReasoningContent = &thinking + } + + return &dto.ChatCompletionResponse{ + ID: id, Object: "chat.completion", Created: time.Now().Unix(), Model: model, + Choices: []dto.Choice{{ + Index: 0, Message: msg, FinishReason: finishReason, + }}, + Usage: *usage, + } +} + +func extractOutputFromResponses(resp *dto.OpenAIResponsesResponse) (string, []dto.ToolCall) { + var textParts []string + var toolCalls []dto.ToolCall + for _, output := range resp.Output { + switch output.Type { + case "message": + if output.Role == "assistant" { + for _, c := range output.Content { + if c.Type == "output_text" && c.Text != "" { + textParts = append(textParts, c.Text) + } + } + } + case "function_call": + toolCalls = append(toolCalls, dto.ToolCall{ + ID: output.CallID, Type: "function", + Function: dto.FunctionCall{Name: output.Name, Arguments: output.Arguments}, + }) + } + } + return strings.Join(textParts, ""), toolCalls +} + +// isModelMapped 判断渠道是否配置了模型映射(上游模型名 ≠ 客户端请求模型名)。 +// Meta 未暴露宿主的 IsModelMapped 标记,以「已附加渠道信息且上游名与原始名不同」等价判定。 +func isModelMapped(info convmeta.Meta) bool { + if info == nil || !info.HasChannelMeta() { + return false + } + upstream := info.GetUpstreamModelName() + return upstream != "" && upstream != info.GetOriginModelName() +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response_test.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response_test.go new file mode 100644 index 0000000..a1ca998 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_response_test.go @@ -0,0 +1,132 @@ +package oai_responses + +import ( + "context" + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +func convertResponsesResponse(t *testing.T, info convmeta.Meta, body string) *dto.ChatCompletionResponse { + t.Helper() + var resp dto.OpenAIResponsesResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("parse responses response: %v", err) + } + conv := &ResponsesToOpenAIResponseConverter{} + out, err := conv.ConvertResponse(context.Background(), info, &resp) + if err != nil { + t.Fatalf("ConvertResponse: %v", err) + } + chatResp, ok := out.(*dto.ChatCompletionResponse) + if !ok { + t.Fatalf("expected *dto.ChatCompletionResponse, got %T", out) + } + return chatResp +} + +func TestResponsesToOpenAIResponse_WrongType(t *testing.T) { + conv := &ResponsesToOpenAIResponseConverter{} + if _, err := conv.ConvertResponse(context.Background(), &convmeta.Values{}, "nope"); err == nil { + t.Fatal("wrong input type should be rejected") + } +} + +// TestResponsesToOpenAIResponse_TextAndToolCalls output 中的 message 文本与 function_call +// 同时存在时:文本进 content,工具调用进 tool_calls,finish_reason 为 stop(有文本)。 +func TestResponsesToOpenAIResponse_TextAndToolCalls(t *testing.T) { + chatResp := convertResponsesResponse(t, &convmeta.Values{OriginModelName: "gpt-4o"}, `{ + "id":"resp_1","object":"response","created_at":100,"status":"completed","model":"gpt-4o-2024", + "output":[ + {"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"Hello "},{"type":"output_text","text":"world"}]}, + {"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"sh\"}"} + ], + "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15, + "input_tokens_details":{"cached_tokens":2,"cache_write_tokens":1,"audio_tokens":0,"text_tokens":8}, + "output_tokens_details":{"reasoning_tokens":3,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}} + }`) + + if chatResp.Object != "chat.completion" { + t.Errorf("object = %q", chatResp.Object) + } + if len(chatResp.Choices) != 1 { + t.Fatalf("choices = %v", chatResp.Choices) + } + choice := chatResp.Choices[0] + if choice.FinishReason != "stop" { + t.Errorf("finish_reason = %q, want stop(有文本时不是 tool_calls)", choice.FinishReason) + } + if choice.Message.Content != "Hello world" { + t.Errorf("content = %v, want 拼接文本", choice.Message.Content) + } + if len(choice.Message.ToolCalls) != 1 { + t.Fatalf("tool_calls = %v", choice.Message.ToolCalls) + } + tc := choice.Message.ToolCalls[0] + if tc.ID != "call_1" || tc.Type != "function" || tc.Function.Name != "get_weather" || tc.Function.Arguments != `{"city":"sh"}` { + t.Errorf("tool_call = %+v", tc) + } + // usage 与明细映射 + if chatResp.Usage.PromptTokens != 10 || chatResp.Usage.CompletionTokens != 5 || chatResp.Usage.TotalTokens != 15 { + t.Errorf("usage = %+v", chatResp.Usage) + } + if d := chatResp.Usage.PromptTokensDetails; d == nil || d.CachedTokens != 2 || d.CacheWriteTokens != 1 || d.TextTokens != 8 { + t.Errorf("prompt details = %+v", chatResp.Usage.PromptTokensDetails) + } + if d := chatResp.Usage.CompletionTokenDetails; d == nil || d.ReasoningTokens != 3 { + t.Errorf("completion details = %+v", chatResp.Usage.CompletionTokenDetails) + } + // 未映射渠道:模型名取上游响应携带的模型名 + if chatResp.Model != "gpt-4o-2024" { + t.Errorf("model = %q, want gpt-4o-2024", chatResp.Model) + } +} + +// TestResponsesToOpenAIResponse_ToolCallsOnly 仅工具调用无文本时 finish_reason 为 tool_calls, +// content 为空字符串。 +func TestResponsesToOpenAIResponse_ToolCallsOnly(t *testing.T) { + chatResp := convertResponsesResponse(t, &convmeta.Values{OriginModelName: "gpt-4o"}, `{ + "id":"resp_1","object":"response","created_at":100,"status":"completed","model":"gpt-4o", + "output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"f","arguments":"{}"}], + "usage":{"input_tokens":4,"output_tokens":2,"total_tokens":0} + }`) + choice := chatResp.Choices[0] + if choice.FinishReason != "tool_calls" { + t.Errorf("finish_reason = %q, want tool_calls", choice.FinishReason) + } + if choice.Message.Content != "" { + t.Errorf("content = %v, want empty", choice.Message.Content) + } + // total_tokens 缺失时补齐为 prompt+completion + if chatResp.Usage.TotalTokens != 6 { + t.Errorf("total_tokens = %d, want 6", chatResp.Usage.TotalTokens) + } +} + +// TestResponsesToOpenAIResponse_UsageEstimated 上游未返回 usage 时按文本长度估算(4 字符/token)。 +func TestResponsesToOpenAIResponse_UsageEstimated(t *testing.T) { + chatResp := convertResponsesResponse(t, &convmeta.Values{OriginModelName: "gpt-4o"}, `{ + "id":"resp_1","object":"response","created_at":100,"status":"completed","model":"gpt-4o", + "output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"12345678"}]}] + }`) + if chatResp.Usage.CompletionTokens != 2 { + t.Errorf("completion_tokens = %d, want 2 (8 字符 / 4)", chatResp.Usage.CompletionTokens) + } + if chatResp.Usage.TotalTokens != 2 { + t.Errorf("total_tokens = %d, want 2", chatResp.Usage.TotalTokens) + } +} + +// TestResponsesToOpenAIResponse_ModelMapped 模型映射渠道回写客户端请求的模型名。 +func TestResponsesToOpenAIResponse_ModelMapped(t *testing.T) { + info := &convmeta.Values{OriginModelName: "my-gpt", UpstreamModelName: "gpt-4o-upstream", ChannelMetaAttached: true} + chatResp := convertResponsesResponse(t, info, `{ + "id":"resp_1","object":"response","created_at":100,"status":"completed","model":"gpt-4o-upstream", + "output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"hi"}]}] + }`) + if chatResp.Model != "my-gpt" { + t.Errorf("model = %q, want my-gpt(映射渠道回写原始模型名)", chatResp.Model) + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go new file mode 100644 index 0000000..1f24a98 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go @@ -0,0 +1,350 @@ +package oai_responses + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" +) + +// ResponsesToOpenAIStreamConverter 将 Responses API 流式响应实时转换为 Chat Completions 流式响应。 +// 输出为裸 *dto.ChatCompletionStreamResponse chunk(OpenAI 风格数据帧,宿主负责 SSE 帧化与 [DONE])。 +type ResponsesToOpenAIStreamConverter struct{} + +func (c *ResponsesToOpenAIStreamConverter) ID() string { + return relayconvert.ResponseConverterOAIResponsesToOAIChatStream +} + +func (c *ResponsesToOpenAIStreamConverter) From() types.RelayFormat { + return types.RelayFormatOpenAIResponses +} + +func (c *ResponsesToOpenAIStreamConverter) To() types.RelayFormat { + return types.RelayFormatOpenAI +} + +func (c *ResponsesToOpenAIStreamConverter) Quality() relayconvert.ResponseConverterQuality { + return relayconvert.ResponseConverterQualityGood +} + +// ConvertStreamResponse 将 Responses API SSE 事件流转换为 Chat Completions chunk 流。 +// reader 提供上游 Responses SSE 事件,转换后的 chunk 通过 chunkWriter 回调写出。 +func (c *ResponsesToOpenAIStreamConverter) ConvertStreamResponse( + ctx context.Context, + info convmeta.Meta, + reader io.Reader, + chunkWriter func(chunk any) error, +) error { + scanner := bufio.NewScanner(reader) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 10*1024*1024) + + responseID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) + createAt := time.Now().Unix() + // 模型名:未映射时取上游模型名(response.created / response.completed 事件可覆盖); + // 模型映射时一律用原始请求模型名,不向客户端泄漏上游真实模型(与非流式转换器口径一致) + model := "" + if info != nil { + model = info.GetUpstreamModelName() + if model == "" || isModelMapped(info) { + model = info.GetOriginModelName() + } + } + + var ( + totalUsage dto.UsageWithDetails + usageText, outputText strings.Builder + sentStart, sentStop bool + sawToolCall bool + ) + + toolCallIndexByID := make(map[string]int) + toolCallNameByID := make(map[string]string) + toolCallArgsByID := make(map[string]string) + toolCallNameSent := make(map[string]bool) + + sendStartIfNeeded := func() error { + if sentStart { + return nil + } + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{Role: "assistant", Content: ""}}}, + } + if err := chunkWriter(chunk); err != nil { + return err + } + sentStart = true + return nil + } + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Text() + if line == "" { + continue + } + // Responses SSE 的 event: 行不参与解析(data 负载自带 type 字段) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" { + continue + } + if data == "[DONE]" { + break + } + + var streamResp dto.ResponsesStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + // 解析失败视为软错误跳过该行(与旧实现的 sr.Error 行为一致:流继续处理) + continue + } + + switch streamResp.Type { + case "response.created": + if streamResp.Response != nil { + // 模型映射时不回填上游模型名,避免泄漏给客户端 + if streamResp.Response.Model != "" && !isModelMapped(info) { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + } + + case "response.reasoning_summary_text.delta": + if streamResp.Delta == "" { + continue + } + if err := sendStartIfNeeded(); err != nil { + return fmt.Errorf("send start chunk failed: %w", err) + } + usageText.WriteString(streamResp.Delta) + delta := streamResp.Delta + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{ReasoningContent: &delta}}}, + } + if err := chunkWriter(chunk); err != nil { + return fmt.Errorf("send reasoning chunk failed: %w", err) + } + + case "response.output_text.delta": + if err := sendStartIfNeeded(); err != nil { + return fmt.Errorf("send start chunk failed: %w", err) + } + if streamResp.Delta != "" { + outputText.WriteString(streamResp.Delta) + usageText.WriteString(streamResp.Delta) + delta := streamResp.Delta + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{Content: delta}}}, + } + if err := chunkWriter(chunk); err != nil { + return fmt.Errorf("send text chunk failed: %w", err) + } + } + + case "response.output_item.added", "response.output_item.done": + if streamResp.Item == nil || streamResp.Item.Type != "function_call" { + continue + } + callID := strings.TrimSpace(streamResp.Item.CallID) + if callID == "" { + callID = strings.TrimSpace(streamResp.Item.ID) + } + if callID == "" { + continue + } + name := strings.TrimSpace(streamResp.Item.Name) + if name != "" { + toolCallNameByID[callID] = name + } + // added/done 事件的 arguments 是累计值:与已发送前缀做差得到增量,避免重复下发 + newArgs := streamResp.Item.Arguments + prevArgs := toolCallArgsByID[callID] + var argsDelta string + if newArgs != "" { + if strings.HasPrefix(newArgs, prevArgs) { + argsDelta = newArgs[len(prevArgs):] + } else { + argsDelta = newArgs + } + toolCallArgsByID[callID] = newArgs + } + if err := sendToolCallChunk(chunkWriter, responseID, createAt, model, callID, name, argsDelta, toolCallIndexByID, toolCallNameByID, toolCallNameSent); err != nil { + return fmt.Errorf("send tool call chunk failed: %w", err) + } + sawToolCall = true + usageText.WriteString(name) + usageText.WriteString(argsDelta) + + case "response.function_call_arguments.delta": + callID := strings.TrimSpace(streamResp.ItemID) + if callID == "" { + continue + } + toolCallArgsByID[callID] += streamResp.Delta + if err := sendToolCallChunk(chunkWriter, responseID, createAt, model, callID, "", streamResp.Delta, toolCallIndexByID, toolCallNameByID, toolCallNameSent); err != nil { + return fmt.Errorf("send tool call args chunk failed: %w", err) + } + sawToolCall = true + usageText.WriteString(streamResp.Delta) + + case "response.completed": + if streamResp.Response != nil { + // 模型映射时不回填上游模型名,避免泄漏给客户端 + if streamResp.Response.Model != "" && !isModelMapped(info) { + model = streamResp.Response.Model + } + if streamResp.Response.CreatedAt != 0 { + createAt = int64(streamResp.Response.CreatedAt) + } + if streamResp.Response.Usage != nil { + totalUsage = responsesUsageToUsageWithDetails(streamResp.Response.Usage) + if totalUsage.TotalTokens == 0 { + totalUsage.TotalTokens = totalUsage.PromptTokens + totalUsage.CompletionTokens + } + } + } + if err := sendStartIfNeeded(); err != nil { + return fmt.Errorf("send start chunk failed: %w", err) + } + if !sentStop { + finishReason := "stop" + if sawToolCall && outputText.Len() == 0 { + finishReason = "tool_calls" + } + fr := finishReason + stopChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, FinishReason: &fr}}, + } + if err := chunkWriter(stopChunk); err != nil { + return fmt.Errorf("send stop chunk failed: %w", err) + } + sentStop = true + } + + case "response.error", "response.failed": + errMsg := "responses stream error" + if streamResp.Response != nil && streamResp.Response.Error != nil { + if b, err := json.Marshal(streamResp.Response.Error); err == nil { + errMsg = string(b) + } + } + return fmt.Errorf("%s: %s", streamResp.Type, errMsg) + } + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return fmt.Errorf("stream scanner error: %w", err) + } + + // 上游未返回 usage 时按累计文本估算(4 字符/token;流中断兜底由宿主处理) + if totalUsage.TotalTokens == 0 && usageText.Len() > 0 { + estimated := usageText.Len() / 4 + totalUsage.CompletionTokens = estimated + totalUsage.TotalTokens = totalUsage.PromptTokens + estimated + } + if !sentStart { + if err := sendStartIfNeeded(); err != nil { + return fmt.Errorf("send start chunk failed: %w", err) + } + } + if !sentStop { + fr := "stop" + stopChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, FinishReason: &fr}}, + } + if err := chunkWriter(stopChunk); err != nil { + return fmt.Errorf("send stop chunk failed: %w", err) + } + } + if totalUsage.TotalTokens > 0 { + usageChunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{}, + Usage: &dto.UsageWithDetails{PromptTokens: totalUsage.PromptTokens, CompletionTokens: totalUsage.CompletionTokens, TotalTokens: totalUsage.TotalTokens, PromptTokensDetails: totalUsage.PromptTokensDetails, CompletionTokenDetails: totalUsage.CompletionTokenDetails}, + } + if err := chunkWriter(usageChunk); err != nil { + return fmt.Errorf("send usage chunk failed: %w", err) + } + } + return nil +} + +// sendToolCallChunk 下发一个工具调用增量 chunk(移植宿主 r2cSendToolCallChunk)。 +// codex 客户端 serde 严格:FunctionCall.Arguments 无 omitempty,序列化恒带 arguments 键; +// name 仅在首个 chunk 携带(nameSent 去重)。 +func sendToolCallChunk(chunkWriter func(chunk any) error, responseID string, createAt int64, model string, callID string, name string, argsDelta string, indexByID map[string]int, nameByID map[string]string, nameSent map[string]bool) error { + idx, ok := indexByID[callID] + if !ok { + idx = len(indexByID) + indexByID[callID] = idx + } + if name != "" { + nameByID[callID] = name + } + if nameByID[callID] != "" { + name = nameByID[callID] + } + tool := dto.ToolCall{ID: callID, Type: "function", Index: idx, Function: dto.FunctionCall{Arguments: argsDelta}} + if name != "" && !nameSent[callID] { + tool.Function.Name = name + nameSent[callID] = true + } + chunk := &dto.ChatCompletionStreamResponse{ + ID: responseID, Object: "chat.completion.chunk", Created: createAt, Model: model, + Choices: []dto.StreamChoice{{Index: 0, Delta: dto.Message{ToolCalls: []dto.ToolCall{tool}}}}, + } + return chunkWriter(chunk) +} + +// responsesUsageToUsageWithDetails 将 Responses API usage 转换为带明细的 usage +// (移植宿主 responsesUsageToCommon 的字段映射;CacheIncludedInPrompt 语义由宿主处理)。 +func responsesUsageToUsageWithDetails(u *dto.ResponsesUsage) dto.UsageWithDetails { + usage := dto.UsageWithDetails{} + if u == nil { + return usage + } + usage.PromptTokens = u.InputTokens + usage.CompletionTokens = u.OutputTokens + usage.TotalTokens = u.TotalTokens + if d := u.InputTokensDetails; d != nil { + usage.PromptTokensDetails = &dto.TokenDetails{ + CachedTokens: d.CachedTokens, + CacheWriteTokens: d.CacheWriteTokens, + TextTokens: d.TextTokens, + AudioTokens: d.AudioTokens, + ImageTokens: d.ImageTokens, + } + } + if d := u.OutputTokenDetails; d != nil { + usage.CompletionTokenDetails = &dto.TokenDetails{ + TextTokens: d.TextTokens, + AudioTokens: d.AudioTokens, + ReasoningTokens: d.ReasoningTokens, + AcceptedPredictionTokens: d.AcceptedPredictionTokens, + RejectedPredictionTokens: d.RejectedPredictionTokens, + } + } + return usage +} diff --git a/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go new file mode 100644 index 0000000..3723295 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go @@ -0,0 +1,210 @@ +package oai_responses + +import ( + "context" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// runResponsesToChatStream 执行 Responses SSE → chat chunk 流式转换并收集全部输出 chunk。 +func runResponsesToChatStream(t *testing.T, info convmeta.Meta, sse string) ([]*dto.ChatCompletionStreamResponse, error) { + t.Helper() + conv := &ResponsesToOpenAIStreamConverter{} + var chunks []*dto.ChatCompletionStreamResponse + err := conv.ConvertStreamResponse(context.Background(), info, strings.NewReader(sse), func(chunk any) error { + c, ok := chunk.(*dto.ChatCompletionStreamResponse) + if !ok { + t.Fatalf("expected *dto.ChatCompletionStreamResponse, got %T", chunk) + } + chunks = append(chunks, c) + return nil + }) + return chunks, err +} + +// TestResponsesToOpenAIStream_TextWithUsage 文本流 + usage: +// 首帧 role/content 空串 → 文本 delta → stop → 末尾 usage chunk(含缓存明细透传)。 +func TestResponsesToOpenAIStream_TextWithUsage(t *testing.T) { + sse := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":123,"status":"in_progress","model":"gpt-4o-real"}}`, + ``, + `data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hel"}`, + ``, + `data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"lo"}`, + ``, + `data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":123,"status":"completed","model":"gpt-4o-real","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"input_tokens_details":{"cached_tokens":2},"output_tokens_details":{"reasoning_tokens":1}}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + chunks, err := runResponsesToChatStream(t, &convmeta.Values{UpstreamModelName: "gpt-4o", ChannelMetaAttached: true, OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + // 序列:start(role) + delta("Hel") + delta("lo") + stop + usage + if len(chunks) != 5 { + t.Fatalf("chunks = %d, want 5", len(chunks)) + } + start := chunks[0] + if start.Choices[0].Delta.Role != "assistant" || start.Choices[0].Delta.Content != "" { + t.Errorf("start chunk = %+v", start.Choices[0].Delta) + } + if start.Object != "chat.completion.chunk" { + t.Errorf("object = %q", start.Object) + } + // response.created 覆盖模型名与创建时间 + if start.Model != "gpt-4o-real" || start.Created != 123 { + t.Errorf("model/created = %v/%v, want gpt-4o-real/123", start.Model, start.Created) + } + if chunks[1].Choices[0].Delta.Content != "Hel" || chunks[2].Choices[0].Delta.Content != "lo" { + t.Errorf("text deltas = %v / %v", chunks[1].Choices[0].Delta.Content, chunks[2].Choices[0].Delta.Content) + } + stop := chunks[3] + if stop.Choices[0].FinishReason == nil || *stop.Choices[0].FinishReason != "stop" { + t.Errorf("finish_reason = %v, want stop", stop.Choices[0].FinishReason) + } + usageChunk := chunks[4] + if len(usageChunk.Choices) != 0 || usageChunk.Usage == nil { + t.Fatalf("usage chunk = %+v", usageChunk) + } + if usageChunk.Usage.PromptTokens != 10 || usageChunk.Usage.CompletionTokens != 5 || usageChunk.Usage.TotalTokens != 15 { + t.Errorf("usage = %+v", usageChunk.Usage) + } + if d := usageChunk.Usage.PromptTokensDetails; d == nil || d.CachedTokens != 2 { + t.Errorf("prompt details = %+v(缓存明细不能丢)", usageChunk.Usage.PromptTokensDetails) + } + if d := usageChunk.Usage.CompletionTokenDetails; d == nil || d.ReasoningTokens != 1 { + t.Errorf("completion details = %+v", usageChunk.Usage.CompletionTokenDetails) + } +} + +// TestResponsesToOpenAIStream_ToolCalls 工具调用流:output_item.added 携带 name, +// function_call_arguments.delta 增量透传(arguments 键恒存在,codex serde 兼容), +// 纯工具调用 finish_reason 为 tool_calls。 +func TestResponsesToOpenAIStream_ToolCalls(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":""}}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"{\"city\":"}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"\"sh\"}"}`, + ``, + `data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":123,"status":"completed","usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + chunks, err := runResponsesToChatStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + // 序列:tool chunk(name) + args delta ×2 + start + stop + usage + // (首个事件为 output_item.added 时 start 帧在 completed 时补发) + var toolChunks []*dto.ChatCompletionStreamResponse + var stopChunk *dto.ChatCompletionStreamResponse + for _, c := range chunks { + if len(c.Choices) > 0 && len(c.Choices[0].Delta.ToolCalls) > 0 { + toolChunks = append(toolChunks, c) + } + if len(c.Choices) > 0 && c.Choices[0].FinishReason != nil { + stopChunk = c + } + } + if len(toolChunks) != 3 { + t.Fatalf("tool chunks = %d, want 3", len(toolChunks)) + } + first := toolChunks[0].Choices[0].Delta.ToolCalls[0] + if first.ID != "call_1" || first.Type != "function" || first.Function.Name != "get_weather" || first.Index != 0 { + t.Errorf("first tool chunk = %+v", first) + } + // name 只在首个 chunk 下发一次 + second := toolChunks[1].Choices[0].Delta.ToolCalls[0] + if second.Function.Name != "" || second.Function.Arguments != `{"city":` { + t.Errorf("second tool chunk = %+v", second) + } + third := toolChunks[2].Choices[0].Delta.ToolCalls[0] + if third.Function.Arguments != `"sh"}` { + t.Errorf("third tool chunk = %+v", third) + } + if stopChunk == nil || *stopChunk.Choices[0].FinishReason != "tool_calls" { + t.Fatalf("stop chunk = %+v, want finish_reason=tool_calls", stopChunk) + } + last := chunks[len(chunks)-1] + if last.Usage == nil || last.Usage.TotalTokens != 10 { + t.Errorf("usage chunk = %+v", last) + } +} + +// TestResponsesToOpenAIStream_ReasoningDelta 推理摘要 delta 转 reasoning_content。 +func TestResponsesToOpenAIStream_ReasoningDelta(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.reasoning_summary_text.delta","item_id":"msg_1","output_index":0,"summary_index":0,"delta":"thinking..."}`, + ``, + `data: {"type":"response.completed","response":{"id":"r","created_at":1,"status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + chunks, err := runResponsesToChatStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + var found bool + for _, c := range chunks { + if len(c.Choices) > 0 && c.Choices[0].Delta.ReasoningContent != nil && *c.Choices[0].Delta.ReasoningContent == "thinking..." { + found = true + } + } + if !found { + t.Error("reasoning delta chunk not found") + } +} + +// TestResponsesToOpenAIStream_UsageEstimated 上游未返回 usage 时按累计文本估算(4 字符/token)。 +func TestResponsesToOpenAIStream_UsageEstimated(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.output_text.delta","item_id":"m","output_index":0,"content_index":0,"delta":"12345678"}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + chunks, err := runResponsesToChatStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + last := chunks[len(chunks)-1] + if last.Usage == nil || last.Usage.CompletionTokens != 2 || last.Usage.TotalTokens != 2 { + t.Errorf("estimated usage = %+v, want completion=2 (8 字符 / 4)", last.Usage) + } + // 上游没有 completed 事件也要补 stop 帧 + var sawStop bool + for _, c := range chunks { + if len(c.Choices) > 0 && c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason == "stop" { + sawStop = true + } + } + if !sawStop { + t.Error("missing fallback stop chunk") + } +} + +// TestResponsesToOpenAIStream_FailedEvent response.failed / response.error 事件终止转换并报错。 +func TestResponsesToOpenAIStream_FailedEvent(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.failed","response":{"id":"r","created_at":1,"status":"failed","error":{"code":"server_error","message":"boom"}}}`, + ``, + }, "\n") + _, err := runResponsesToChatStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err == nil { + t.Fatal("response.failed should terminate the stream with error") + } + if !strings.Contains(err.Error(), "response.failed") || !strings.Contains(err.Error(), "boom") { + t.Errorf("error = %v", err) + } +} diff --git a/relaykit/relayconvert/internal/oai_responses/testmeta_test.go b/relaykit/relayconvert/internal/oai_responses/testmeta_test.go new file mode 100644 index 0000000..b0671b6 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/testmeta_test.go @@ -0,0 +1,29 @@ +package oai_responses + +import ( + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// stashMeta 测试用 Meta 实现:嵌入 convmeta.Values 并实现 ResponsesStash 能力接口 +// (对应宿主 RelayInfo 的 info.ResponsesRequest 字段)。 +type stashMeta struct { + *convmeta.Values + stashed *dto.OpenAIResponsesRequest +} + +func newStashMeta(origin, upstream string, hasChannel bool) *stashMeta { + return &stashMeta{Values: &convmeta.Values{ + OriginModelName: origin, + UpstreamModelName: upstream, + ChannelMetaAttached: hasChannel, + }} +} + +func (m *stashMeta) StashResponsesRequest(req *dto.OpenAIResponsesRequest) { + m.stashed = req +} + +func (m *stashMeta) StashedResponsesRequest() *dto.OpenAIResponsesRequest { + return m.stashed +} diff --git a/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go b/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go index 9220548..bcfb7eb 100644 --- a/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go +++ b/relaykit/relayconvert/internal/ollama_chat/openai_to_ollama_request.go @@ -64,6 +64,7 @@ func (c *OpenAIToOllamaRequestConverter) ConvertRequest( om := dto.OllamaMessage{ Role: msg.Role, Content: extractOllamaContent(msg.Content), + Images: extractOllamaImages(msg.Content), } if msg.Role == "tool" { om.ToolCallID = msg.ToolCallID @@ -132,6 +133,15 @@ func extractOllamaContent(content any) string { switch c := content.(type) { case string: return c + case []dto.ContentPart: + // 链式转换产出的类型化部件列表 + var sb strings.Builder + for _, part := range c { + if part.Type == "text" { + sb.WriteString(part.Text) + } + } + return sb.String() case []any: var sb strings.Builder for _, part := range c { @@ -151,6 +161,61 @@ func extractOllamaContent(content any) string { } } +// extractOllamaImages 从多模态 content 中提取图片:Ollama 以 base64 数组承载图片 +// (OllamaMessage.Images),data URL 形式的 image_url 剥出 base64 载荷; +// http(s) 远程图片 Ollama 无法引用,跳过。 +func extractOllamaImages(content any) []string { + appendDataURL := func(images []string, url string) []string { + if _, data, ok := parseImageDataURL(url); ok { + return append(images, data) + } + return images + } + + var images []string + switch c := content.(type) { + case []dto.ContentPart: + for _, part := range c { + if part.Type == "image_url" && part.ImageURL != nil { + images = appendDataURL(images, part.ImageURL.URL) + } + } + case []any: + for _, part := range c { + m, ok := part.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t != "image_url" { + continue + } + if iu, ok := m["image_url"].(map[string]any); ok { + if url, ok := iu["url"].(string); ok { + images = appendDataURL(images, url) + } + } + } + } + return images +} + +// parseImageDataURL 解析 data:image/;base64, 形式的 data URL, +// 返回 MIME 类型与 base64 载荷。 +func parseImageDataURL(dataURL string) (mimeType, data string, ok bool) { + if !strings.HasPrefix(dataURL, "data:") { + return "", "", false + } + commaIdx := strings.IndexByte(dataURL, ',') + if commaIdx < 0 { + return "", "", false + } + header := dataURL[5:commaIdx] + if !strings.HasSuffix(header, ";base64") { + return "", "", false + } + return strings.TrimSuffix(header, ";base64"), dataURL[commaIdx+1:], true +} + // buildOllamaOptions 从 OpenAI 请求参数构建 Ollama options 映射。 // 映射关系:temperature→temperature、top_p→top_p、max_tokens/max_completion_tokens→num_predict、 // stop→stop、frequency_penalty→frequency_penalty、presence_penalty→presence_penalty。 diff --git a/relaykit/relayconvert/internal/shared/gemini_schema.go b/relaykit/relayconvert/internal/shared/gemini_schema.go new file mode 100644 index 0000000..3e8cb1b --- /dev/null +++ b/relaykit/relayconvert/internal/shared/gemini_schema.go @@ -0,0 +1,168 @@ +package shared + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// geminiSchemaAllowedKeys Gemini Schema 支持的 JSON Schema 关键字白名单。 +// OpenAI 的 function.parameters 与 Claude 的 input_schema 同源,都可能携带 +// $schema、additionalProperties、$defs 等 Gemini 会拒绝的关键字,需按此过滤。 +var geminiSchemaAllowedKeys = map[string]struct{}{ + "type": {}, "description": {}, "properties": {}, "required": {}, "items": {}, + "anyOf": {}, "default": {}, "enum": {}, "format": {}, "maxLength": {}, + "minLength": {}, "maximum": {}, "minimum": {}, "pattern": {}, "title": {}, + "nullable": {}, "maxItems": {}, "minItems": {}, "maxProperties": {}, + "minProperties": {}, "example": {}, +} + +// CleanGeminiToolParams 把 OpenAI function.parameters / Claude input_schema 归一化 +// 为 Gemini Schema 接受的形态,claude→gemini 与 openai→gemini 两条请求路径共用。 +// +// 早期版本只做顶层白名单过滤、嵌套原样透传,但 Gemini 实际并不宽容——线上真实流量 +// (Claude Code 的 Grep 工具)携带 JSON Schema 2020-12 的 prefixItems 元组语法, +// Gemini 丢弃该未知关键字后留下无 items 的裸数组,直接 400: +// +// GenerateContentRequest...properties[where].items.items: missing field +// +// 因此这里递归做四件事: +// 1. 逐层白名单过滤($schema / $defs / additionalProperties 等 Gemini 拒绝的关键字) +// 2. prefixItems 元组折叠为 items.anyOf(Gemini 无元组语义,取各位置 schema 的并集近似) +// 3. 缺 type 的节点按内容推断兜底(properties→object / items→array / enum→string, +// 其余→string)——Gemini 要求每个 Schema 节点有确定类型,空节点同样被拒 +// 4. type=array 却无 items 的节点补 items(Gemini 对数组元素 schema 有硬性要求) +// +// 非对象输入(含 nil)原样返回。 +func CleanGeminiToolParams(params any) any { + return cleanGeminiSchema(params) +} + +func cleanGeminiSchema(node any) any { + m, ok := node.(map[string]any) + if !ok { + return node + } + out := make(map[string]any, len(m)+1) + for k, v := range m { + if _, allowed := geminiSchemaAllowedKeys[k]; !allowed { + continue + } + switch k { + case "properties": + if props, ok := v.(map[string]any); ok { + converted := make(map[string]any, len(props)) + for pk, pv := range props { + converted[pk] = cleanGeminiSchema(pv) + } + out[k] = converted + } else { + out[k] = v + } + case "items": + out[k] = cleanGeminiSchema(v) + case "anyOf": + if arr, ok := v.([]any); ok { + converted := make([]any, len(arr)) + for i, el := range arr { + converted[i] = cleanGeminiSchema(el) + } + out[k] = converted + } else { + out[k] = v + } + default: + out[k] = v + } + } + + // prefixItems 元组 → items.anyOf 并集近似(prefixItems 不在白名单内, + // 上面的循环不会写出它;这里从原始节点读取后折叠,避免裸数组漏出去) + if arr, ok := m["prefixItems"].([]any); ok && len(arr) > 0 { + anyOf := make([]any, 0, len(arr)+1) + for _, el := range arr { + anyOf = append(anyOf, cleanGeminiSchema(el)) + } + // 2020-12 语义:items 约束 prefixItems 之后的尾部元素,并入并集 + if prev, ok := out["items"].(map[string]any); ok { + anyOf = append(anyOf, prev) + } + out["items"] = map[string]any{"anyOf": anyOf} + } + + // type 兜底。携带 anyOf 的并集节点不加 type——那是 Gemini 自身的 union 形态 + if _, hasType := out["type"]; !hasType { + if _, hasAnyOf := out["anyOf"]; !hasAnyOf { + switch { + case out["properties"] != nil: + out["type"] = "object" + case out["items"] != nil: + out["type"] = "array" + case out["enum"] != nil: + out["type"] = "string" + default: + out["type"] = "string" + } + } + } + + // 数组必须携带元素 schema:Gemini 对裸数组直接 400(missing field), + // 无法推断元素类型时退化为 string(工具参数 schema 只是对模型的提示, + // 合法性优先于精确性) + if t, _ := out["type"].(string); strings.EqualFold(t, "array") { + if _, hasItems := out["items"]; !hasItems { + out["items"] = map[string]any{"type": "string"} + } + } + return out +} + +// ToolResultToGeminiResponse 把工具输出归一化为 Gemini functionResponse.response +// 可接受的值。Gemini 的该字段是 google.protobuf.Struct——**必须是 JSON 对象**: +// 纯文本(shell 输出等)、数组、标量一律包成 {"result": ...},原样传字符串会被 +// protojson 直接 400(线上实测:codex 的 shell 工具输出 "Chunk ID: ...\nOutput:\n{...}" +// 混合文本 → Invalid value at function_response.response (Struct))。 +// 对象原样透传;nil / 空串返回空对象。 +func ToolResultToGeminiResponse(content any) any { + switch v := content.(type) { + case nil: + return map[string]any{} + case map[string]any: + return v + case string: + if v == "" { + return map[string]any{} + } + var parsed any + if json.Unmarshal([]byte(v), &parsed) == nil { + if m, ok := parsed.(map[string]any); ok { + return m + } + } + return map[string]any{"result": v} + case []any: + var texts []string + for _, item := range v { + m, ok := item.(map[string]any) + if !ok || m["type"] != "text" { + continue + } + if text, ok := m["text"].(string); ok { + texts = append(texts, text) + } + } + return map[string]any{"result": strings.Join(texts, "\n")} + case []dto.ClaudeContentBlock: + var texts []string + for i := range v { + if v[i].Text != nil { + texts = append(texts, *v[i].Text) + } + } + return map[string]any{"result": strings.Join(texts, "\n")} + default: + return map[string]any{"result": fmt.Sprintf("%v", v)} + } +} diff --git a/relaykit/relayconvert/internal/shared/gemini_schema_test.go b/relaykit/relayconvert/internal/shared/gemini_schema_test.go new file mode 100644 index 0000000..1bb1c88 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/gemini_schema_test.go @@ -0,0 +1,184 @@ +package shared + +import ( + "encoding/json" + "strings" + "testing" +) + +// assertGeminiSchemaValid 递归校验输出满足 Gemini 的硬性约束: +// - 每个节点都有 type,或是 anyOf 并集节点 +// - type=array 的节点必须带 items +// - 不含白名单外的关键字(如 prefixItems / $schema / additionalProperties) +func assertGeminiSchemaValid(t *testing.T, node any, path string) { + t.Helper() + m, ok := node.(map[string]any) + if !ok { + t.Fatalf("%s: 应为对象节点,实际 %T", path, node) + } + _, hasType := m["type"] + _, hasAnyOf := m["anyOf"] + if !hasType && !hasAnyOf { + t.Errorf("%s: 节点缺 type(Gemini 拒绝空/无类型 Schema): %v", path, m) + } + if hasType { + if typ, _ := m["type"].(string); strings.EqualFold(typ, "array") { + if _, hasItems := m["items"]; !hasItems { + t.Errorf("%s: 数组节点缺 items(Gemini 报 missing field)", path) + } + } + } + for k, v := range m { + if _, allowed := geminiSchemaAllowedKeys[k]; !allowed { + t.Errorf("%s: 含白名单外关键字 %q", path, k) + } + switch k { + case "properties": + for pk, pv := range v.(map[string]any) { + assertGeminiSchemaValid(t, pv, path+".properties."+pk) + } + case "items": + assertGeminiSchemaValid(t, v, path+".items") + case "anyOf": + for i, el := range v.([]any) { + assertGeminiSchemaValid(t, el, path+".anyOf["+string(rune('0'+i))+"]") + } + } + } +} + +// TestCleanGeminiToolParams_PrefixItemsTuple 线上真实形状(2026-09 采集的 Claude Code +// Grep 工具 where 参数):JSON Schema 2020-12 的 prefixItems 元组 + 空元素节点。 +// 曾导致 Gemini 400:GenerateContentRequest...properties[where].items.items: missing field +// (Gemini 丢弃 prefixItems 后剩下无 items 的裸数组)。 +func TestCleanGeminiToolParams_PrefixItemsTuple(t *testing.T) { + // 取自语料 real_inputs 的真实 schema(工具名与枚举值已脱敏,结构原样) + raw := `{ + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "where": { + "type": "array", + "maxItems": 10, + "items": { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"enum": ["AND", "OR"], "type": "string"}, + {} + ] + } + }, + "path": {"type": "array", "items": {"type": "string"}}, + "pattern": {"type": "string"} + } + } + } +}` + var in map[string]any + if err := json.Unmarshal([]byte(raw), &in); err != nil { + t.Fatal(err) + } + + out := CleanGeminiToolParams(in).(map[string]any) + assertGeminiSchemaValid(t, out, "schema") + + if s := JSONString(t, out); strings.Contains(s, "prefixItems") { + t.Errorf("prefixItems 应被折叠,不应出现在输出: %s", s) + } + // where.items 应变为 {type:array, items:{anyOf:[...]}},三元组成员各带 type + where := out["properties"].(map[string]any)["query"].(map[string]any)["properties"].(map[string]any)["where"].(map[string]any) + items := where["items"].(map[string]any) + folded, ok := items["items"].(map[string]any) + if !ok { + t.Fatalf("where.items.items 应为折叠后的 anyOf 容器: %s", JSONString(t, items)) + } + anyOf, ok := folded["anyOf"].([]any) + if !ok || len(anyOf) != 3 { + t.Fatalf("where.items.items.anyOf 应含 3 个成员(含空节点兜底): %s", JSONString(t, items)) + } +} + +// TestCleanGeminiToolParams_NestedWhitelist 白名单过滤必须递归到嵌套层级—— +// 早期版本只过滤顶层,嵌套的 $defs / additionalProperties 原样透传。 +func TestCleanGeminiToolParams_NestedWhitelist(t *testing.T) { + raw := `{ + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "loc": { + "type": "object", + "properties": {"city": {"type": "string", "$defs": {}, "additionalProperties": false}}, + "additionalProperties": false + } + } +}` + var in map[string]any + if err := json.Unmarshal([]byte(raw), &in); err != nil { + t.Fatal(err) + } + out := CleanGeminiToolParams(in).(map[string]any) + assertGeminiSchemaValid(t, out, "schema") + if s := JSONString(t, out); strings.Contains(s, "$schema") || strings.Contains(s, "additionalProperties") || strings.Contains(s, "$defs") { + t.Errorf("嵌套层级的非白名单关键字未被过滤: %s", s) + } +} + +// TestCleanGeminiToolParams_BareArrayAndTypeFallback 裸数组补 items、 +// 无 type 节点按内容推断兜底。 +func TestCleanGeminiToolParams_BareArrayAndTypeFallback(t *testing.T) { + raw := `{ + "type": "object", + "properties": { + "tags": {"type": "array", "maxItems": 10}, + "note": {"description": "无类型字段"}, + "meta": {"properties": {"k": {"enum": ["a", "b"]}}}, + "empty": {} + } +}` + var in map[string]any + if err := json.Unmarshal([]byte(raw), &in); err != nil { + t.Fatal(err) + } + out := CleanGeminiToolParams(in).(map[string]any) + assertGeminiSchemaValid(t, out, "schema") + + props := out["properties"].(map[string]any) + if got := JSONString(t, props["tags"]); !strings.Contains(got, `"items"`) { + t.Errorf("裸数组应补 items: %s", got) + } + if got := props["note"].(map[string]any)["type"]; got != "string" { + t.Errorf("无类型节点应兜底 string,实际 %v", got) + } + if got := props["meta"].(map[string]any)["type"]; got != "object" { + t.Errorf("含 properties 的节点应兜底 object,实际 %v", got) + } + if got := props["meta"].(map[string]any)["properties"].(map[string]any)["k"].(map[string]any)["type"]; got != "string" { + t.Errorf("含 enum 的节点应兜底 string,实际 %v", got) + } + if got := props["empty"].(map[string]any)["type"]; got != "string" { + t.Errorf("空节点应兜底 string,实际 %v", got) + } +} + +// TestCleanGeminiToolParams_NonMapPassthrough 非对象输入(nil / 字符串等)原样返回。 +func TestCleanGeminiToolParams_NonMapPassthrough(t *testing.T) { + if got := CleanGeminiToolParams(nil); got != nil { + t.Errorf("nil 应原样返回,实际 %v", got) + } + s := "not-a-schema" + if got := CleanGeminiToolParams(s); got != s { + t.Errorf("字符串应原样返回,实际 %v", got) + } +} + +func JSONString(t *testing.T, v any) string { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/relaykit/relayconvert/internal/shared/grounding.go b/relaykit/relayconvert/internal/shared/grounding.go new file mode 100644 index 0000000..2e6b179 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/grounding.go @@ -0,0 +1,302 @@ +package shared + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// 服务端联网搜索的**响应侧**证据(引用来源)跨协议中间表示。 +// +// 请求侧的能力映射见 websearch.go:客户端要求联网 → 各格式的原生搜索构件。 +// 但上游真的去搜了之后,「搜到了什么」同样需要还原回客户端协议,否则客户端只拿到 +// 一段被搜索增强的纯文本——**看不到来源、无法核查、也无法展示引用**,而这正是 +// 联网搜索的主要价值。各格式的表达方式: +// +// Gemini candidate.groundingMetadata:groundingChunks(来源)+ +// groundingSupports(文本片段→来源索引)+ webSearchQueries(实际查询) +// Claude content 块对:server_tool_use(发起搜索)+ web_search_tool_result(结果列表) +// 另有 usage.server_tool_use.web_search_requests 供按次计费 +// OpenAI message.annotations:[{type:"url_citation", url_citation:{url,title,start_index,end_index}}] +// Responses output_text.annotations:[{type:"url_citation", url, title, start_index, end_index}] +// +// 本文件只做协议还原;按次计费的定价挂接属宿主计费层,此处仅把 +// web_search_requests 计数透出到 usage,使计费层有据可依。 + +// GroundingSource 一条被引用的网页来源。 +type GroundingSource struct { + URL string + Title string +} + +// GroundingSupport 回答文本的一个片段与其来源的对应关系。 +// StartIndex / EndIndex 是**字节**偏移(与 Gemini 一致,Go 字符串切片同为字节口径)。 +type GroundingSupport struct { + StartIndex int + EndIndex int + Text string // 片段原文,用于校验偏移是否仍然对齐 + SourceIndices []int // 指向 GroundingCitations.Sources +} + +// GroundingCitations 一次带搜索的回答所附带的全部引用证据。 +type GroundingCitations struct { + Queries []string + Sources []GroundingSource + Supports []GroundingSupport +} + +// IsEmpty 无来源即视为没有可还原的引用证据(只有 queries 而无来源时, +// 客户端拿不到任何可核查的东西,不值得合成空的工具结果块)。 +func (c *GroundingCitations) IsEmpty() bool { + return c == nil || len(c.Sources) == 0 +} + +// SearchRequestCount 本次回答实际发起的搜索次数,供计费层按次计价。 +// Gemini 以 webSearchQueries 列出实际查询;缺失但有来源时按 1 次计。 +func (c *GroundingCitations) SearchRequestCount() int { + if c.IsEmpty() { + return 0 + } + if n := len(c.Queries); n > 0 { + return n + } + return 1 +} + +// queryText 把多个查询合并为单个 query 字符串。 +// Claude 的 server_tool_use.input 是单个 query,而 Gemini 把多次查询的结果**汇集**在 +// 一组 groundingChunks 里、无法回溯每条来源属于哪次查询,故合并表达而非拆成多对 +// (拆开会导致来源在各对之间重复,虚增引用数)。真实次数由 SearchRequestCount 承载。 +func (c *GroundingCitations) queryText() string { + if len(c.Queries) == 0 { + return "" + } + return strings.Join(c.Queries, ", ") +} + +// ---------- 解析:Gemini groundingMetadata ---------- + +// geminiGroundingMetadata 是 groundingMetadata 的解析形态。 +// DTO 侧保持 `any` 以保证原生透传方向的 wire 保真(未知字段不丢), +// 这里按需重解析出所需字段。 +type geminiGroundingMetadata struct { + WebSearchQueries []string `json:"webSearchQueries"` + GroundingChunks []struct { + Web *struct { + URI string `json:"uri"` + Title string `json:"title"` + } `json:"web"` + } `json:"groundingChunks"` + GroundingSupports []struct { + Segment *struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + Text string `json:"text"` + } `json:"segment"` + GroundingChunkIndices []int `json:"groundingChunkIndices"` + } `json:"groundingSupports"` +} + +// ParseGeminiGrounding 从 candidate.groundingMetadata 提取引用证据。 +// 无 grounding、解析失败、或无任何网页来源时返回 nil(调用方据此不做还原)。 +func ParseGeminiGrounding(meta any) *GroundingCitations { + if meta == nil { + return nil + } + raw, err := json.Marshal(meta) + if err != nil { + return nil + } + var parsed geminiGroundingMetadata + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil + } + + citations := &GroundingCitations{Queries: parsed.WebSearchQueries} + // chunk 下标 → Sources 下标的重映射:非 web 类型的 chunk(如检索库片段) + // 不产生来源,会让两侧下标错位,必须显式重映射而非直接沿用原下标 + chunkToSource := make(map[int]int, len(parsed.GroundingChunks)) + for i, chunk := range parsed.GroundingChunks { + if chunk.Web == nil || chunk.Web.URI == "" { + continue + } + chunkToSource[i] = len(citations.Sources) + citations.Sources = append(citations.Sources, GroundingSource{ + URL: chunk.Web.URI, + Title: chunk.Web.Title, + }) + } + if len(citations.Sources) == 0 { + return nil + } + + for _, support := range parsed.GroundingSupports { + if support.Segment == nil { + continue + } + indices := make([]int, 0, len(support.GroundingChunkIndices)) + for _, ci := range support.GroundingChunkIndices { + if si, ok := chunkToSource[ci]; ok { + indices = append(indices, si) + } + } + if len(indices) == 0 { + continue + } + citations.Supports = append(citations.Supports, GroundingSupport{ + StartIndex: support.Segment.StartIndex, + EndIndex: support.Segment.EndIndex, + Text: support.Segment.Text, + SourceIndices: indices, + }) + } + return citations +} + +// AlignSupports 校正片段偏移,使其对齐**转换后**的回答文本。 +// +// 为什么必须校正:Gemini 的偏移是相对上游原始候选文本的,而转换后的正文可能与之不同 +// (思考 part 被剥离、多个 text part 被拼接、前缀被裁剪)。偏移错位会让客户端把引用 +// 高亮在错误的位置上——比没有引用更糟。 +// +// 策略:segment.text 给出了片段原文,据此校验;对不上就在正文里重新定位; +// 仍找不到则丢弃该片段的偏移(保留来源本身,只是不带位置)。 +func (c *GroundingCitations) AlignSupports(text string) { + if c == nil || len(c.Supports) == 0 { + return + } + aligned := make([]GroundingSupport, 0, len(c.Supports)) + for _, s := range c.Supports { + if s.Text == "" { + continue // 无原文可校验,不敢保留偏移 + } + // 原偏移仍然对齐:直接沿用 + if s.StartIndex >= 0 && s.EndIndex <= len(text) && s.StartIndex < s.EndIndex && + text[s.StartIndex:s.EndIndex] == s.Text { + aligned = append(aligned, s) + continue + } + // 否则在正文中重新定位 + if idx := strings.Index(text, s.Text); idx >= 0 { + s.StartIndex = idx + s.EndIndex = idx + len(s.Text) + aligned = append(aligned, s) + } + // 找不到:该片段不带偏移地丢弃,来源本身仍在 Sources 里 + } + c.Supports = aligned +} + +// ---------- 产出:Claude ---------- + +// ToClaudeSearchBlocks 产出 Claude 的 server_tool_use + web_search_tool_result 块对。 +// Gemini 不提供 Anthropic 的 encrypted_content / page_age,仅还原 url + title—— +// 能力(来源可见、可核查)保住,细粒度字段静默降级。 +func (c *GroundingCitations) ToClaudeSearchBlocks(idSeed string) []dto.ClaudeContentBlock { + if c.IsEmpty() { + return nil + } + toolUseID := fmt.Sprintf("srvtoolu_%s", idSeed) + + results := make([]map[string]any, 0, len(c.Sources)) + for _, src := range c.Sources { + results = append(results, map[string]any{ + "type": "web_search_result", + "url": src.URL, + "title": src.Title, + }) + } + + return []dto.ClaudeContentBlock{ + { + Type: "server_tool_use", + ID: toolUseID, + Name: "web_search", + Input: map[string]any{"query": c.queryText()}, + }, + { + Type: "web_search_tool_result", + ToolUseID: toolUseID, + Content: results, + }, + } +} + +// ---------- 产出:OpenAI chat / Responses 的 url_citation ---------- + +// ToOpenAIAnnotations 产出 chat 的 annotations(url_citation 形态)。 +// 有对齐后的片段时按「片段 × 来源」逐条产出并带偏移,否则每个来源产出一条无偏移的引用。 +func (c *GroundingCitations) ToOpenAIAnnotations() []any { + if c.IsEmpty() { + return nil + } + out := make([]any, 0, len(c.Sources)) + emit := func(src GroundingSource, start, end int, withRange bool) { + citation := map[string]any{"url": src.URL, "title": src.Title} + if withRange { + citation["start_index"] = start + citation["end_index"] = end + } + out = append(out, map[string]any{"type": "url_citation", "url_citation": citation}) + } + + if len(c.Supports) > 0 { + for _, s := range c.Supports { + for _, si := range s.SourceIndices { + if si < len(c.Sources) { + emit(c.Sources[si], s.StartIndex, s.EndIndex, true) + } + } + } + return out + } + for _, src := range c.Sources { + emit(src, 0, 0, false) + } + return out +} + +// ToResponsesAnnotations 产出 Responses 的 output_text.annotations(url_citation 形态)。 +func (c *GroundingCitations) ToResponsesAnnotations() []dto.ResponsesAnnotation { + if c.IsEmpty() { + return nil + } + out := make([]dto.ResponsesAnnotation, 0, len(c.Sources)) + if len(c.Supports) > 0 { + for _, s := range c.Supports { + for _, si := range s.SourceIndices { + if si >= len(c.Sources) { + continue + } + out = append(out, dto.ResponsesAnnotation{ + Type: "url_citation", + URL: c.Sources[si].URL, + Title: c.Sources[si].Title, + StartIndex: s.StartIndex, + EndIndex: s.EndIndex, + }) + } + } + return out + } + for _, src := range c.Sources { + out = append(out, dto.ResponsesAnnotation{Type: "url_citation", URL: src.URL, Title: src.Title}) + } + return out +} + +// ToResponsesWebSearchCallItem 产出 Responses 的 web_search_call 输出项, +// 让客户端看到「模型发起过搜索」这一事实(与 annotations 互补:前者是动作,后者是引用)。 +func (c *GroundingCitations) ToResponsesWebSearchCallItem(idSeed string) map[string]any { + if c.IsEmpty() { + return nil + } + return map[string]any{ + "type": "web_search_call", + "id": fmt.Sprintf("ws_%s", idSeed), + "status": "completed", + "action": map[string]any{"type": "search", "query": c.queryText()}, + } +} diff --git a/relaykit/relayconvert/internal/shared/grounding_test.go b/relaykit/relayconvert/internal/shared/grounding_test.go new file mode 100644 index 0000000..c57bbc8 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/grounding_test.go @@ -0,0 +1,257 @@ +package shared + +import ( + "encoding/json" + "testing" +) + +// geminiGroundingFixture 一份贴合真实形态的 groundingMetadata(含两个来源、 +// 一个覆盖片段、两次查询,另含一个非 web 类型的 chunk 用于验证下标重映射)。 +const geminiGroundingFixture = `{ + "searchEntryPoint": {"renderedContent": "
Google Search Suggestions
"}, + "webSearchQueries": ["weather boston", "boston forecast"], + "groundingChunks": [ + {"web": {"uri": "https://weather.example/boston", "title": "weather.example"}}, + {"retrievedContext": {"title": "内部文档"}}, + {"web": {"uri": "https://forecast.example/ma", "title": "forecast.example"}} + ], + "groundingSupports": [ + { + "segment": {"startIndex": 0, "endIndex": 11, "text": "It is 68F in"}, + "groundingChunkIndices": [0, 2] + } + ] +}` + +func parseFixture(t *testing.T, raw string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("语料不是合法 JSON: %v", err) + } + return v +} + +func TestParseGeminiGrounding(t *testing.T) { + c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture)) + if c.IsEmpty() { + t.Fatal("应解析出引用证据") + } + if len(c.Sources) != 2 { + t.Fatalf("Sources 数 = %d, want 2(非 web 类型 chunk 不产生来源): %+v", len(c.Sources), c.Sources) + } + if c.Sources[0].URL != "https://weather.example/boston" || c.Sources[1].URL != "https://forecast.example/ma" { + t.Errorf("来源顺序/内容不符: %+v", c.Sources) + } + if len(c.Queries) != 2 { + t.Errorf("Queries = %v, want 2 条", c.Queries) + } + + // 关键:chunk 下标 [0,2] 必须重映射为 Sources 下标 [0,1]。 + // 直接沿用原下标会让第二条引用指向不存在的 Sources[2](越界或错位到别的来源)。 + if len(c.Supports) != 1 { + t.Fatalf("Supports 数 = %d, want 1", len(c.Supports)) + } + got := c.Supports[0].SourceIndices + if len(got) != 2 || got[0] != 0 || got[1] != 1 { + t.Errorf("chunk 下标未正确重映射: got %v, want [0 1]", got) + } +} + +func TestParseGeminiGrounding_NoSources(t *testing.T) { + cases := map[string]string{ + "nil": "", + "空对象": `{}`, + "只有查询无来源": `{"webSearchQueries":["q"]}`, + "chunk 全为非 web": `{"groundingChunks":[{"retrievedContext":{"title":"x"}}]}`, + "web 缺 uri": `{"groundingChunks":[{"web":{"title":"x"}}]}`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + var meta any + if raw != "" { + meta = parseFixture(t, raw) + } + if c := ParseGeminiGrounding(meta); !c.IsEmpty() { + t.Errorf("无可核查来源时应视为空, got %+v", c) + } + }) + } +} + +func TestSearchRequestCount(t *testing.T) { + c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture)) + if got := c.SearchRequestCount(); got != 2 { + t.Errorf("SearchRequestCount = %d, want 2(按 webSearchQueries 条数)", got) + } + // 有来源但无 queries:按 1 次计,不能算 0(否则按次计费漏记) + noQueries := ParseGeminiGrounding(parseFixture(t, `{"groundingChunks":[{"web":{"uri":"https://a.example"}}]}`)) + if got := noQueries.SearchRequestCount(); got != 1 { + t.Errorf("无 queries 但有来源时 = %d, want 1", got) + } + var empty *GroundingCitations + if got := empty.SearchRequestCount(); got != 0 { + t.Errorf("空证据 = %d, want 0", got) + } +} + +// TestAlignSupports 片段偏移校正:Gemini 的偏移相对上游原始文本, +// 转换后的正文可能不同(剥离思考 part、拼接多个 text part), +// 错位的偏移会让客户端把引用高亮在错误位置——比没有引用更糟。 +func TestAlignSupports(t *testing.T) { + t.Run("偏移已对齐则沿用", func(t *testing.T) { + c := &GroundingCitations{ + Sources: []GroundingSource{{URL: "https://a.example"}}, + Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}}, + } + c.AlignSupports("Hello world") + if len(c.Supports) != 1 || c.Supports[0].StartIndex != 0 || c.Supports[0].EndIndex != 5 { + t.Errorf("对齐的偏移不应被改动: %+v", c.Supports) + } + }) + + t.Run("偏移错位则按原文重新定位", func(t *testing.T) { + c := &GroundingCitations{ + Sources: []GroundingSource{{URL: "https://a.example"}}, + // 上游偏移 0..5,但转换后正文前面多了前缀 + Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}}, + } + c.AlignSupports("prefix: Hello world") + if len(c.Supports) != 1 { + t.Fatalf("应保留并校正该片段: %+v", c.Supports) + } + if c.Supports[0].StartIndex != 8 || c.Supports[0].EndIndex != 13 { + t.Errorf("重新定位错误: start=%d end=%d, want 8/13", c.Supports[0].StartIndex, c.Supports[0].EndIndex) + } + }) + + t.Run("正文中找不到则丢弃偏移但保留来源", func(t *testing.T) { + c := &GroundingCitations{ + Sources: []GroundingSource{{URL: "https://a.example"}}, + Supports: []GroundingSupport{{StartIndex: 0, EndIndex: 5, Text: "Hello", SourceIndices: []int{0}}}, + } + c.AlignSupports("完全不同的正文") + if len(c.Supports) != 0 { + t.Errorf("对不上的片段应丢弃偏移: %+v", c.Supports) + } + if len(c.Sources) != 1 { + t.Error("来源本身必须保留(客户端仍应看到引用了哪些网页)") + } + }) + + t.Run("越界偏移不得 panic", func(t *testing.T) { + c := &GroundingCitations{ + Sources: []GroundingSource{{URL: "https://a.example"}}, + Supports: []GroundingSupport{{StartIndex: 100, EndIndex: 999, Text: "x", SourceIndices: []int{0}}}, + } + c.AlignSupports("短") + }) + + t.Run("多字节字符按字节偏移", func(t *testing.T) { + c := &GroundingCitations{ + Sources: []GroundingSource{{URL: "https://a.example"}}, + Supports: []GroundingSupport{{Text: "波士顿", SourceIndices: []int{0}}}, + } + answer := "今天波士顿很冷" + c.AlignSupports(answer) + if len(c.Supports) != 1 { + t.Fatal("应定位到多字节片段") + } + s := c.Supports[0] + if answer[s.StartIndex:s.EndIndex] != "波士顿" { + t.Errorf("多字节偏移错误: %q", answer[s.StartIndex:s.EndIndex]) + } + }) +} + +func TestToClaudeSearchBlocks(t *testing.T) { + c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture)) + blocks := c.ToClaudeSearchBlocks("seed1") + if len(blocks) != 2 { + t.Fatalf("应产出 server_tool_use + web_search_tool_result 两块: %+v", blocks) + } + if blocks[0].Type != "server_tool_use" || blocks[0].Name != "web_search" { + t.Errorf("首块形态不符: %+v", blocks[0]) + } + if blocks[1].Type != "web_search_tool_result" { + t.Errorf("次块形态不符: %+v", blocks[1]) + } + // 两块必须以同一个 id 关联,否则 Anthropic SDK 无法把结果挂到调用上 + if blocks[1].ToolUseID != blocks[0].ID { + t.Errorf("tool_use_id (%s) 与 server_tool_use.id (%s) 不一致", blocks[1].ToolUseID, blocks[0].ID) + } + results, ok := blocks[1].Content.([]map[string]any) + if !ok || len(results) != 2 { + t.Fatalf("结果列表应含 2 条来源: %+v", blocks[1].Content) + } + if results[0]["type"] != "web_search_result" || results[0]["url"] != "https://weather.example/boston" { + t.Errorf("结果项形态不符: %+v", results[0]) + } + + var empty *GroundingCitations + if empty.ToClaudeSearchBlocks("s") != nil { + t.Error("空证据不应产出块") + } +} + +func TestToOpenAIAnnotations(t *testing.T) { + c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture)) + c.AlignSupports("It is 68F in Boston today") + + anns := c.ToOpenAIAnnotations() + if len(anns) != 2 { + t.Fatalf("一个片段引用两个来源应产出 2 条 annotation: %+v", anns) + } + first := anns[0].(map[string]any) + if first["type"] != "url_citation" { + t.Errorf("type = %v, want url_citation", first["type"]) + } + citation := first["url_citation"].(map[string]any) + if citation["url"] != "https://weather.example/boston" { + t.Errorf("url 不符: %+v", citation) + } + if _, ok := citation["start_index"]; !ok { + t.Error("对齐后的片段应带偏移") + } + + // 无片段时每个来源一条、不带偏移(宁可无位置,也不给错误位置) + noSupport := &GroundingCitations{Sources: []GroundingSource{{URL: "https://a.example"}}} + anns = noSupport.ToOpenAIAnnotations() + if len(anns) != 1 { + t.Fatalf("应按来源产出: %+v", anns) + } + citation = anns[0].(map[string]any)["url_citation"].(map[string]any) + if _, ok := citation["start_index"]; ok { + t.Error("无可信片段时不得附带偏移") + } +} + +func TestToResponsesAnnotationsAndSearchCall(t *testing.T) { + c := ParseGeminiGrounding(parseFixture(t, geminiGroundingFixture)) + c.AlignSupports("It is 68F in Boston today") + + anns := c.ToResponsesAnnotations() + if len(anns) != 2 { + t.Fatalf("annotations 数 = %d, want 2: %+v", len(anns), anns) + } + if anns[0].Type != "url_citation" || anns[0].URL != "https://weather.example/boston" { + t.Errorf("annotation 形态不符: %+v", anns[0]) + } + + item := c.ToResponsesWebSearchCallItem("seed1") + if item == nil { + t.Fatal("应产出 web_search_call 动作项") + } + if item["type"] != "web_search_call" || item["status"] != "completed" { + t.Errorf("动作项形态不符: %+v", item) + } + action := item["action"].(map[string]any) + if action["query"] != "weather boston, boston forecast" { + t.Errorf("query 合并结果 = %v", action["query"]) + } + + var empty *GroundingCitations + if empty.ToResponsesWebSearchCallItem("s") != nil { + t.Error("空证据不应产出动作项") + } +} diff --git a/relaykit/relayconvert/internal/shared/message_mapper.go b/relaykit/relayconvert/internal/shared/message_mapper.go index 24e3356..5577b8a 100644 --- a/relaykit/relayconvert/internal/shared/message_mapper.go +++ b/relaykit/relayconvert/internal/shared/message_mapper.go @@ -3,9 +3,39 @@ package shared import ( + "encoding/json" + "github.com/qianfree/team-api/relaykit/dto" ) +// NormalizeContentParts 将 OpenAI 消息 content 的各种真实形态统一为类型化 ContentPart 列表。 +// +// 线上存在两种多模态形态:宿主对入站请求裸 json.Unmarshal 产生的 []any(元素为 +// map[string]any),以及链式转换(如 Responses→OpenAI→Claude)中上游转换器构造的 +// []dto.ContentPart。任一形态缺失处理都会导致多模态内容(文本/图片/音频)静默丢失, +// 由 register 包的能力守恒测试守护。ok=false 表示 content 不是部件列表(如纯字符串)。 +func NormalizeContentParts(content any) ([]dto.ContentPart, bool) { + switch v := content.(type) { + case []dto.ContentPart: + return v, true + case []any: + parts := make([]dto.ContentPart, 0, len(v)) + for _, item := range v { + switch it := item.(type) { + case dto.ContentPart: + parts = append(parts, it) + case map[string]any: + var p dto.ContentPart + if data, err := json.Marshal(it); err == nil && json.Unmarshal(data, &p) == nil && p.Type != "" { + parts = append(parts, p) + } + } + } + return parts, true + } + return nil, false +} + // MapTextContent 从多种内容格式中抽取纯文本内容。 // 未找到文本时返回空字符串。 func MapTextContent(content any) string { @@ -20,8 +50,18 @@ func MapTextContent(content any) string { } case []any: for _, item := range v { - if part, ok := item.(dto.ContentPart); ok && part.Type == "text" { - return part.Text + switch part := item.(type) { + case dto.ContentPart: + if part.Type == "text" { + return part.Text + } + case map[string]any: + // 宿主裸 unmarshal 的 wire 形态 + if t, _ := part["type"].(string); t == "text" { + if text, ok := part["text"].(string); ok { + return text + } + } } } } diff --git a/relaykit/relayconvert/internal/shared/reasoning_responses.go b/relaykit/relayconvert/internal/shared/reasoning_responses.go new file mode 100644 index 0000000..6924d8a --- /dev/null +++ b/relaykit/relayconvert/internal/shared/reasoning_responses.go @@ -0,0 +1,78 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// Responses 协议的「思考内容」跨协议映射。 +// +// 四种协议表达模型思考过程的方式各不相同: +// +// OpenAI chat message.reasoning_content(字符串) +// Claude content 块 {type: "thinking", thinking: "...", signature: "..."} +// Gemini part {text: "...", thought: true} +// Responses 输出项 {type: "reasoning", id: "rs_x", summary: [{type: "summary_text", text: "..."}]} +// +// 非流式的 Responses 方向此前一律跳过思考内容,理由注释写的是「Responses 非流式 +// 无对应物」——但这是误判:reasoning 输出项本身就是非流式形态(流式侧的 +// response.reasoning_summary_text.delta 只是它的增量表达)。于是推理模型经这些方向 +// 转换后,客户端完全看不到思考过程,且**看不出丢过东西**。 + +// responsesReasoningItemType Responses 输出项中思考项的 type 值。 +const responsesReasoningItemType = "reasoning" + +// responsesSummaryTextType reasoning.summary 数组元素的 type 值。 +const responsesSummaryTextType = "summary_text" + +// BuildResponsesReasoningOutput 把一段思考文本构造为 Responses 的 reasoning 输出项。 +// thinking 为空时返回 nil(调用方据此不追加输出项,避免产出空 reasoning 壳)。 +// +// idSeed 用于生成稳定的项 id:同一响应内多次调用应传入同一 seed, +// 使 id 可复现(金样本比对依赖此性质)。 +func BuildResponsesReasoningOutput(idSeed, thinking string) *dto.ResponsesOutput { + if thinking == "" { + return nil + } + return &dto.ResponsesOutput{ + Type: responsesReasoningItemType, + ID: fmt.Sprintf("rs_%s", idSeed), + Summary: []dto.ResponsesSummaryPart{{ + Type: responsesSummaryTextType, + Text: thinking, + }}, + } +} + +// ExtractResponsesReasoning 从 Responses 输出项中提取思考文本, +// 多个 reasoning 项与多段 summary 按顺序拼接;无思考内容返回空串。 +func ExtractResponsesReasoning(outputs []dto.ResponsesOutput) string { + var parts []string + for _, out := range outputs { + if out.Type != responsesReasoningItemType { + continue + } + for _, s := range out.Summary { + if s.Text != "" { + parts = append(parts, s.Text) + } + } + } + return strings.Join(parts, "") +} + +// ExtractClaudeThinking 从 Claude content 块中提取思考文本(thinking / redacted_thinking)。 +func ExtractClaudeThinking(blocks []dto.ClaudeContentBlock) string { + var parts []string + for _, b := range blocks { + switch b.Type { + case "thinking", "redacted_thinking": + if b.Thinking != nil && *b.Thinking != "" { + parts = append(parts, *b.Thinking) + } + } + } + return strings.Join(parts, "") +} diff --git a/relaykit/relayconvert/internal/shared/reasoning_responses_test.go b/relaykit/relayconvert/internal/shared/reasoning_responses_test.go new file mode 100644 index 0000000..057cc56 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/reasoning_responses_test.go @@ -0,0 +1,110 @@ +package shared + +import ( + "testing" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// Responses 思考内容的构造与提取:任一侧失准都会让推理模型的思考过程在转换中静默消失。 + +func TestBuildResponsesReasoningOutput(t *testing.T) { + item := BuildResponsesReasoningOutput("abc", "让我想想") + if item == nil { + t.Fatal("非空思考内容应产出 reasoning 项") + } + if item.Type != "reasoning" { + t.Errorf("Type = %q, want \"reasoning\"", item.Type) + } + if item.ID != "rs_abc" { + t.Errorf("ID = %q, want \"rs_abc\"(id 须由 seed 稳定派生,金样本比对依赖此性质)", item.ID) + } + if len(item.Summary) != 1 || item.Summary[0].Type != "summary_text" || item.Summary[0].Text != "让我想想" { + t.Errorf("Summary 形态不符: %+v", item.Summary) + } + + // 空思考内容不得产出空壳 + if BuildResponsesReasoningOutput("abc", "") != nil { + t.Error("空思考内容应返回 nil,避免产出空 reasoning 壳") + } +} + +func TestExtractResponsesReasoning(t *testing.T) { + tests := []struct { + name string + outputs []dto.ResponsesOutput + want string + }{ + { + name: "单个 reasoning 项", + outputs: []dto.ResponsesOutput{ + {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Type: "summary_text", Text: "思考"}}}, + {Type: "message"}, + }, + want: "思考", + }, + { + name: "多段 summary 按序拼接", + outputs: []dto.ResponsesOutput{ + {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{ + {Type: "summary_text", Text: "第一段"}, + {Type: "summary_text", Text: "第二段"}, + }}, + }, + want: "第一段第二段", + }, + { + name: "多个 reasoning 项按序拼接", + outputs: []dto.ResponsesOutput{ + {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Text: "A"}}}, + {Type: "message"}, + {Type: "reasoning", Summary: []dto.ResponsesSummaryPart{{Text: "B"}}}, + }, + want: "AB", + }, + {name: "无 reasoning 项", outputs: []dto.ResponsesOutput{{Type: "message"}}, want: ""}, + {name: "空 summary", outputs: []dto.ResponsesOutput{{Type: "reasoning"}}, want: ""}, + {name: "空输出", outputs: nil, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ExtractResponsesReasoning(tt.outputs); got != tt.want { + t.Errorf("ExtractResponsesReasoning() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractClaudeThinking(t *testing.T) { + think := "想一想" + empty := "" + blocks := []dto.ClaudeContentBlock{ + {Type: "thinking", Thinking: &think}, + {Type: "redacted_thinking", Thinking: &empty}, + {Type: "text"}, + } + if got := ExtractClaudeThinking(blocks); got != "想一想" { + t.Errorf("ExtractClaudeThinking() = %q, want %q", got, "想一想") + } + // 无 thinking 块 + if got := ExtractClaudeThinking([]dto.ClaudeContentBlock{{Type: "text"}}); got != "" { + t.Errorf("无思考块应返回空串, got %q", got) + } + // nil Thinking 指针不得 panic + if got := ExtractClaudeThinking([]dto.ClaudeContentBlock{{Type: "thinking"}}); got != "" { + t.Errorf("nil Thinking 应返回空串, got %q", got) + } +} + +// TestResponsesReasoning_RoundTrip 构造后再提取必须还原原文—— +// 这是「chat reasoning_content ↔ Responses reasoning」双向转换不丢内容的前提。 +func TestResponsesReasoning_RoundTrip(t *testing.T) { + const original = "第一步:分析问题\n第二步:得出结论" + item := BuildResponsesReasoningOutput("seed", original) + if item == nil { + t.Fatal("构造失败") + } + if got := ExtractResponsesReasoning([]dto.ResponsesOutput{*item}); got != original { + t.Errorf("往返后思考内容变化:\n got=%q\nwant=%q", got, original) + } +} diff --git a/relaykit/relayconvert/internal/shared/thinking_adapter.go b/relaykit/relayconvert/internal/shared/thinking_adapter.go deleted file mode 100644 index 00a9298..0000000 --- a/relaykit/relayconvert/internal/shared/thinking_adapter.go +++ /dev/null @@ -1,112 +0,0 @@ -package shared - -import ( - "strings" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/relayconvert/reasoning" -) - -// ThinkingInfo 保存从模型名后缀解析得到的 thinking 配置。 -type ThinkingInfo struct { - BaseModel string // 不带后缀的模型名 - IsThinking bool // 带 -thinking 后缀 - IsNoThinking bool // 带 -nothinking 后缀 - EffortLevel string // low/medium/high/xhigh/max/minimal/none -} - -// ParseThinkingSuffix 从模型名中解析 thinking 相关后缀。 -// 处理:-thinking、-nothinking、-low、-medium、-high、-xhigh、-max、-minimal、-none -func ParseThinkingSuffix(modelName string) ThinkingInfo { - info := ThinkingInfo{ - BaseModel: modelName, - } - - // 检查 -thinking 后缀 - if strings.HasSuffix(modelName, "-thinking") { - info.IsThinking = true - info.BaseModel = strings.TrimSuffix(modelName, "-thinking") - return info - } - - // 检查 -nothinking 后缀 - if strings.HasSuffix(modelName, "-nothinking") { - info.IsNoThinking = true - info.BaseModel = strings.TrimSuffix(modelName, "-nothinking") - return info - } - - // 检查 effort 等级后缀 - baseModel, effort, found := reasoning.TrimEffortSuffix(modelName) - if found { - info.BaseModel = baseModel - info.EffortLevel = effort - } - - return info -} - -// ApplyThinkingToClaude 将 thinking 配置应用到 Claude 请求。 -func ApplyThinkingToClaude(req *dto.ClaudeRequest, info ThinkingInfo, opts convmeta.ClaudeOptions) { - if !opts.ThinkingAdapterEnabled { - return - } - - // 显式禁用 thinking - if info.IsNoThinking { - return - } - - // 启用 thinking - if info.IsThinking || info.EffortLevel != "" { - req.Thinking = &dto.ClaudeThinking{ - Type: "enabled", - } - - // 若已配置则设置 budget tokens - if req.MaxTokens != nil && opts.ThinkingAdapterBudgetTokensPercentage > 0 { - budgetTokens := int(float64(*req.MaxTokens) * opts.ThinkingAdapterBudgetTokensPercentage) - if budgetTokens > 0 { - req.Thinking.BudgetTokens = &budgetTokens - } - } - } -} - -// ApplyThinkingToGemini 将 thinking 配置应用到 Gemini 请求。 -func ApplyThinkingToGemini(config *dto.GeminiGenerationConfig, info ThinkingInfo, opts convmeta.GeminiOptions) { - if !opts.ThinkingAdapterEnabled { - return - } - - // 显式禁用 thinking - if info.IsNoThinking { - return - } - - // 启用 thinking - if info.IsThinking || info.EffortLevel != "" { - thinkingConfig := &dto.GeminiThinkingConfig{ - IncludeThoughts: true, - } - - // 将 effort 等级映射为 thinking budget - if config.MaxOutputTokens != nil && opts.ThinkingAdapterBudgetTokensPercentage > 0 { - thinkingBudget := int(float64(*config.MaxOutputTokens) * opts.ThinkingAdapterBudgetTokensPercentage) - if thinkingBudget > 0 { - thinkingConfig.ThoughtBudget = &thinkingBudget - } - } - - config.ThinkingConfig = thinkingConfig - } -} - -// ShouldPreserveThinkingSuffix 检查是否应在上游模型名上保留 thinking 后缀。 -func ShouldPreserveThinkingSuffix(modelName string, opts *convmeta.Options) bool { - if opts == nil { - return false - } - return opts.ShouldPreserveThinkingSuffix(modelName) -} diff --git a/relaykit/relayconvert/internal/shared/thinking_adapter_test.go b/relaykit/relayconvert/internal/shared/thinking_adapter_test.go deleted file mode 100644 index 7d57cb9..0000000 --- a/relaykit/relayconvert/internal/shared/thinking_adapter_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package shared - -import ( - "testing" - - "github.com/qianfree/team-api/relaykit/dto" - "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" -) - -func TestParseThinkingSuffix(t *testing.T) { - tests := []struct { - name string - modelName string - expectBase string - expectThink bool - expectNoThink bool - expectEffort string - }{ - { - name: "thinking suffix", - modelName: "gpt-4-thinking", - expectBase: "gpt-4", - expectThink: true, - }, - { - name: "nothinking suffix", - modelName: "gpt-4-nothinking", - expectBase: "gpt-4", - expectNoThink: true, - }, - { - name: "low effort", - modelName: "gpt-4-low", - expectBase: "gpt-4", - expectEffort: "low", - }, - { - name: "high effort", - modelName: "gpt-4-high", - expectBase: "gpt-4", - expectEffort: "high", - }, - { - name: "no suffix", - modelName: "gpt-4", - expectBase: "gpt-4", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - info := ParseThinkingSuffix(tt.modelName) - - if info.BaseModel != tt.expectBase { - t.Errorf("BaseModel = %q, want %q", info.BaseModel, tt.expectBase) - } - - if info.IsThinking != tt.expectThink { - t.Errorf("IsThinking = %v, want %v", info.IsThinking, tt.expectThink) - } - - if info.IsNoThinking != tt.expectNoThink { - t.Errorf("IsNoThinking = %v, want %v", info.IsNoThinking, tt.expectNoThink) - } - - if info.EffortLevel != tt.expectEffort { - t.Errorf("EffortLevel = %q, want %q", info.EffortLevel, tt.expectEffort) - } - }) - } -} - -func TestApplyThinkingToClaude(t *testing.T) { - tests := []struct { - name string - info ThinkingInfo - opts convmeta.ClaudeOptions - maxTokens *uint - expectThinking bool - expectBudget bool - }{ - { - name: "adapter disabled", - info: ThinkingInfo{IsThinking: true}, - opts: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: false, - }, - expectThinking: false, - }, - { - name: "thinking enabled without budget", - info: ThinkingInfo{IsThinking: true}, - opts: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: true, - }, - expectThinking: true, - expectBudget: false, - }, - { - name: "thinking enabled with budget", - info: ThinkingInfo{IsThinking: true}, - maxTokens: uintPtr(1000), - opts: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: true, - ThinkingAdapterBudgetTokensPercentage: 0.2, - }, - expectThinking: true, - expectBudget: true, - }, - { - name: "nothinking suppresses thinking", - info: ThinkingInfo{IsNoThinking: true}, - opts: convmeta.ClaudeOptions{ - ThinkingAdapterEnabled: true, - }, - expectThinking: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := &dto.ClaudeRequest{ - MaxTokens: tt.maxTokens, - } - - ApplyThinkingToClaude(req, tt.info, tt.opts) - - if tt.expectThinking { - if req.Thinking == nil { - t.Error("Expected Thinking to be set, got nil") - } else { - if req.Thinking.Type != "enabled" { - t.Errorf("Expected Thinking.Type = 'enabled', got %q", req.Thinking.Type) - } - - if tt.expectBudget { - if req.Thinking.BudgetTokens == nil { - t.Error("Expected BudgetTokens to be set, got nil") - } - } - } - } else { - if req.Thinking != nil { - t.Error("Expected Thinking to be nil, got non-nil") - } - } - }) - } -} - -func TestApplyThinkingToGemini(t *testing.T) { - tests := []struct { - name string - info ThinkingInfo - opts convmeta.GeminiOptions - maxTokens *uint - expectThinking bool - expectBudget bool - }{ - { - name: "adapter disabled", - info: ThinkingInfo{IsThinking: true}, - opts: convmeta.GeminiOptions{ - ThinkingAdapterEnabled: false, - }, - expectThinking: false, - }, - { - name: "thinking enabled with effort", - info: ThinkingInfo{EffortLevel: "high"}, - opts: convmeta.GeminiOptions{ - ThinkingAdapterEnabled: true, - }, - expectThinking: true, - }, - { - name: "thinking with budget", - info: ThinkingInfo{IsThinking: true}, - maxTokens: uintPtr(2000), - opts: convmeta.GeminiOptions{ - ThinkingAdapterEnabled: true, - ThinkingAdapterBudgetTokensPercentage: 0.3, - }, - expectThinking: true, - expectBudget: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := &dto.GeminiGenerationConfig{ - MaxOutputTokens: tt.maxTokens, - } - - ApplyThinkingToGemini(config, tt.info, tt.opts) - - if tt.expectThinking { - if config.ThinkingConfig == nil { - t.Error("Expected ThinkingConfig to be set, got nil") - } else { - if !config.ThinkingConfig.IncludeThoughts { - t.Error("Expected IncludeThoughts = true, got false") - } - - if tt.expectBudget { - if config.ThinkingConfig.ThoughtBudget == nil { - t.Error("Expected ThoughtBudget to be set, got nil") - } - } - } - } else { - if config.ThinkingConfig != nil { - t.Error("Expected ThinkingConfig to be nil, got non-nil") - } - } - }) - } -} - -func uintPtr(i uint) *uint { - return &i -} - -func intPtr(i int) *int { - return &i -} diff --git a/relaykit/relayconvert/internal/shared/tool_mapper.go b/relaykit/relayconvert/internal/shared/tool_mapper.go index db13295..1b09771 100644 --- a/relaykit/relayconvert/internal/shared/tool_mapper.go +++ b/relaykit/relayconvert/internal/shared/tool_mapper.go @@ -32,10 +32,15 @@ func MapOpenAIToolsToClaudeTools(tools []dto.Tool) []dto.ClaudeTool { } // MapClaudeToolsToOpenAITools 将 Claude ClaudeTool[] 转换为 OpenAI Tool[]。 +// 服务端内置工具(type 非空且非 custom,如 web_search_*)在 chat 协议无对应物, +// 跳过而非伪装成自定义函数。 func MapClaudeToolsToOpenAITools(tools []dto.ClaudeTool) []dto.Tool { openaiTools := make([]dto.Tool, 0, len(tools)) for _, tool := range tools { + if tool.Type != "" && tool.Type != "custom" { + continue + } openaiTool := dto.Tool{ Type: "function", Function: dto.FunctionDef{ diff --git a/relaykit/relayconvert/internal/shared/websearch.go b/relaykit/relayconvert/internal/shared/websearch.go new file mode 100644 index 0000000..740f092 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/websearch.go @@ -0,0 +1,178 @@ +package shared + +import ( + "encoding/json" + "strings" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// 服务端联网搜索(server-side web search)的跨协议中间表示。 +// +// 四种文本协议都有「让模型自己去联网搜」的原生构件,但表达方式完全不同: +// +// OpenAI chat 顶层参数 web_search_options: {search_context_size, user_location} +// Claude 工具项 {type: "web_search_20250305", name: "web_search", max_uses, allowed_domains, ...} +// Gemini 工具项 {googleSearch: {}} +// Responses 工具项 {type: "web_search"}(旧版 web_search_preview) +// Ollama 无 —— 真实能力缺失,只能降级 +// +// 此前各方向要么原样丢弃、要么把它当成自定义函数伪造,导致**静默能力丢失**: +// 客户端明确要求联网、请求却以「没搜索」的方式成功返回,比直接报错更难排查。 +// 本文件把它抽成一个格式无关的 spec:各转换器只负责「从源格式探测」与「产出目标格式」, +// 于是经 OpenAI 中枢的两跳链(如 Gemini→OpenAI→Claude)也能自动保住该能力 +// —— chat 的 web_search_options 正是链路中枢的载体。 + +// claudeWebSearchToolType 产出 Claude web_search 工具时使用的版本化类型标识。 +// Anthropic 的服务端工具按日期版本化,跨协议映射时取当前稳定版。 +const claudeWebSearchToolType = "web_search_20250305" + +// claudeWebSearchToolName Claude web_search 工具的固定名称。 +const claudeWebSearchToolName = "web_search" + +// WebSearchSpec 服务端联网搜索的格式无关描述。 +// 除 Enabled 外的字段是各协议的可选约束,目标格式无对应物时静默降级 +// (能力本身保住,细粒度约束丢失——这与「整个搜索能力丢失」是两个量级)。 +type WebSearchSpec struct { + // MaxUses 最大搜索次数(Claude 独有) + MaxUses *int + // AllowedDomains / BlockedDomains 域名白/黑名单(Claude 独有) + AllowedDomains []string + BlockedDomains []string + // UserLocation 地理位置提示(Claude 与 OpenAI chat 均支持,形态不同,原样透传) + UserLocation any + // SearchContextSize 搜索上下文规模 low/medium/high(OpenAI chat 独有) + SearchContextSize string +} + +// DetectWebSearchFromOpenAI 从 chat 请求的 web_search_options 探测搜索能力。 +// 该字段存在(哪怕是空对象 {})即表示启用——这是 OpenAI 的约定。 +func DetectWebSearchFromOpenAI(opts json.RawMessage) *WebSearchSpec { + if len(opts) == 0 || string(opts) == "null" { + return nil + } + spec := &WebSearchSpec{} + var parsed struct { + SearchContextSize string `json:"search_context_size"` + UserLocation any `json:"user_location"` + } + if err := json.Unmarshal(opts, &parsed); err == nil { + spec.SearchContextSize = parsed.SearchContextSize + spec.UserLocation = parsed.UserLocation + } + return spec +} + +// DetectWebSearchFromClaudeTools 从 Claude tools 中探测 web_search 内置工具。 +// 内置工具带日期版本后缀(web_search_20250305 等),按前缀识别。 +func DetectWebSearchFromClaudeTools(tools []dto.ClaudeTool) *WebSearchSpec { + for _, t := range tools { + if !strings.HasPrefix(t.Type, "web_search") { + continue + } + return &WebSearchSpec{ + MaxUses: t.MaxUses, + AllowedDomains: t.AllowedDomains, + BlockedDomains: t.BlockedDomains, + UserLocation: t.UserLocation, + } + } + return nil +} + +// DetectWebSearchFromGeminiTools 从 Gemini tools 中探测 googleSearch(含旧版 +// googleSearchRetrieval)条目。tools 为 repeated Tool,需逐条目检查。 +func DetectWebSearchFromGeminiTools(toolsJSON json.RawMessage) *WebSearchSpec { + if len(toolsJSON) == 0 { + return nil + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal(toolsJSON, &entries); err != nil { + return nil + } + for _, entry := range entries { + if _, ok := entry["googleSearch"]; ok { + return &WebSearchSpec{} + } + if _, ok := entry["googleSearchRetrieval"]; ok { + return &WebSearchSpec{} + } + } + return nil +} + +// DetectWebSearchFromResponsesTools 从 Responses tools 中探测 web_search 工具// (含 web_search_preview / web_search_2025xx 等变体,按前缀识别)。 +func DetectWebSearchFromResponsesTools(toolsJSON json.RawMessage) *WebSearchSpec { + if len(toolsJSON) == 0 { + return nil + } + var tools []map[string]any + if err := json.Unmarshal(toolsJSON, &tools); err != nil { + return nil + } + for _, tool := range tools { + typ, _ := tool["type"].(string) + if !strings.HasPrefix(typ, "web_search") { + continue + } + spec := &WebSearchSpec{} + if loc, ok := tool["user_location"]; ok { + spec.UserLocation = loc + } + if size, ok := tool["search_context_size"].(string); ok { + spec.SearchContextSize = size + } + return spec + } + return nil +} + +// ToOpenAIOptions 产出 chat 的 web_search_options。 +// 无可表达的约束时产出空对象 {}——对 OpenAI 而言「字段存在」即启用。 +func (s *WebSearchSpec) ToOpenAIOptions() json.RawMessage { + if s == nil { + return nil + } + opts := map[string]any{} + if s.SearchContextSize != "" { + opts["search_context_size"] = s.SearchContextSize + } + if s.UserLocation != nil { + opts["user_location"] = s.UserLocation + } + raw, err := json.Marshal(opts) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +// ToClaudeTool 产出 Claude 的 web_search 内置工具项。 +func (s *WebSearchSpec) ToClaudeTool() dto.ClaudeTool { + return dto.ClaudeTool{ + Type: claudeWebSearchToolType, + Name: claudeWebSearchToolName, + MaxUses: s.MaxUses, + AllowedDomains: s.AllowedDomains, + BlockedDomains: s.BlockedDomains, + UserLocation: s.UserLocation, + } +} + +// ToResponsesTool 产出 Responses 的 web_search 工具项。 +func (s *WebSearchSpec) ToResponsesTool() map[string]any { + tool := map[string]any{"type": "web_search"} + if s.UserLocation != nil { + tool["user_location"] = s.UserLocation + } + if s.SearchContextSize != "" { + tool["search_context_size"] = s.SearchContextSize + } + return tool +} + +// GeminiGoogleSearchEntry 产出 Gemini tools 数组中的 googleSearch 条目。 +// Gemini 侧无任何可承载的约束字段,MaxUses / 域名白名单等一律静默降级。 +func GeminiGoogleSearchEntry() map[string]any { + return map[string]any{"googleSearch": map[string]any{}} +} diff --git a/relaykit/relayconvert/internal/shared/websearch_test.go b/relaykit/relayconvert/internal/shared/websearch_test.go new file mode 100644 index 0000000..8733d30 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/websearch_test.go @@ -0,0 +1,184 @@ +package shared + +import ( + "encoding/json" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" +) + +// 服务端联网搜索的跨协议中间表示:探测与产出两侧都要覆盖, +// 任一侧失准都会让能力在转换中静默丢失(客户端要求联网、却以没搜索的方式成功返回)。 + +func TestDetectWebSearchFromOpenAI(t *testing.T) { + tests := []struct { + name string + opts string + wantNil bool + wantSize string + }{ + {"空对象即启用", `{}`, false, ""}, + {"带上下文规模", `{"search_context_size":"high"}`, false, "high"}, + {"带位置", `{"user_location":{"type":"approximate","approximate":{"city":"SH"}}}`, false, ""}, + {"字段缺失", ``, true, ""}, + {"显式 null", `null`, true, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectWebSearchFromOpenAI(json.RawMessage(tt.opts)) + if tt.wantNil { + if got != nil { + t.Fatalf("期望未探测到搜索,got %+v", got) + } + return + } + if got == nil { + t.Fatal("应探测到搜索能力") + } + if got.SearchContextSize != tt.wantSize { + t.Errorf("SearchContextSize = %q, want %q", got.SearchContextSize, tt.wantSize) + } + }) + } +} + +func TestDetectWebSearchFromClaudeTools(t *testing.T) { + maxUses := 5 + tools := []dto.ClaudeTool{ + {Name: "get_weather", InputSchema: map[string]any{}}, + {Type: "web_search_20250305", Name: "web_search", MaxUses: &maxUses, AllowedDomains: []string{"example.com"}}, + } + got := DetectWebSearchFromClaudeTools(tools) + if got == nil { + t.Fatal("应在混合工具列表中探测到 web_search") + } + if got.MaxUses == nil || *got.MaxUses != 5 { + t.Errorf("MaxUses 丢失: %v", got.MaxUses) + } + if len(got.AllowedDomains) != 1 || got.AllowedDomains[0] != "example.com" { + t.Errorf("AllowedDomains 丢失: %v", got.AllowedDomains) + } + + // 仅自定义函数时不得误报 + if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Name: "f"}}) != nil { + t.Error("纯自定义函数不应被识别为搜索") + } + // 其他内置工具不得误报 + if DetectWebSearchFromClaudeTools([]dto.ClaudeTool{{Type: "code_execution_20250522", Name: "code"}}) != nil { + t.Error("code_execution 不应被识别为搜索") + } +} + +func TestDetectWebSearchFromGeminiTools(t *testing.T) { + cases := []struct { + name string + tools string + want bool + }{ + {"googleSearch", `[{"googleSearch":{}}]`, true}, + {"旧版 googleSearchRetrieval", `[{"googleSearchRetrieval":{}}]`, true}, + {"与函数声明共存", `[{"functionDeclarations":[{"name":"f"}]},{"googleSearch":{}}]`, true}, + {"仅函数声明", `[{"functionDeclarations":[{"name":"f"}]}]`, false}, + {"空", ``, false}, + {"畸形 JSON", `{not json`, false}, + // 对象形态(非数组)不是合法 Gemini tools,不应误判 + {"对象形态", `{"googleSearch":{}}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DetectWebSearchFromGeminiTools(json.RawMessage(tc.tools)) != nil + if got != tc.want { + t.Errorf("探测结果 = %v, want %v", got, tc.want) + } + }) + } +} + +func TestDetectWebSearchFromResponsesTools(t *testing.T) { + cases := []struct { + name string + tools string + want bool + }{ + {"web_search", `[{"type":"web_search"}]`, true}, + {"web_search_preview 变体", `[{"type":"web_search_preview"}]`, true}, + {"与函数共存", `[{"type":"function","name":"f"},{"type":"web_search"}]`, true}, + {"仅函数", `[{"type":"function","name":"f"}]`, false}, + {"空", ``, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DetectWebSearchFromResponsesTools(json.RawMessage(tc.tools)) != nil + if got != tc.want { + t.Errorf("探测结果 = %v, want %v", got, tc.want) + } + }) + } + + spec := DetectWebSearchFromResponsesTools(json.RawMessage(`[{"type":"web_search","search_context_size":"low"}]`)) + if spec == nil || spec.SearchContextSize != "low" { + t.Errorf("search_context_size 未提取: %+v", spec) + } +} + +// TestWebSearchSpec_RoundTripAcrossFormats 跨格式往返:任一格式产出的构件 +// 再被对应的探测器识别,能力都不得丢失——这是两跳链(经 chat 中枢)能保住搜索的前提。 +func TestWebSearchSpec_RoundTripAcrossFormats(t *testing.T) { + maxUses := 3 + origin := &WebSearchSpec{MaxUses: &maxUses, SearchContextSize: "high"} + + // → chat → 回探测 + chatOpts := origin.ToOpenAIOptions() + back := DetectWebSearchFromOpenAI(chatOpts) + if back == nil { + t.Fatal("chat 往返后能力丢失") + } + if back.SearchContextSize != "high" { + t.Errorf("chat 往返丢失 search_context_size: %+v", back) + } + + // → Claude → 回探测 + claudeTool := origin.ToClaudeTool() + back = DetectWebSearchFromClaudeTools([]dto.ClaudeTool{claudeTool}) + if back == nil { + t.Fatal("Claude 往返后能力丢失") + } + if back.MaxUses == nil || *back.MaxUses != 3 { + t.Errorf("Claude 往返丢失 max_uses: %+v", back.MaxUses) + } + + // → Responses → 回探测 + respToolRaw, err := json.Marshal([]any{origin.ToResponsesTool()}) + if err != nil { + t.Fatalf("marshal 失败: %v", err) + } + if DetectWebSearchFromResponsesTools(respToolRaw) == nil { + t.Fatal("Responses 往返后能力丢失") + } + + // → Gemini → 回探测(Gemini 侧无可承载的约束字段,只保能力本身) + geminiRaw, err := json.Marshal([]any{GeminiGoogleSearchEntry()}) + if err != nil { + t.Fatalf("marshal 失败: %v", err) + } + if DetectWebSearchFromGeminiTools(geminiRaw) == nil { + t.Fatal("Gemini 往返后能力丢失") + } +} + +// TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled 无约束时必须产出空对象而非 null: +// OpenAI 的约定是「字段存在即启用」,产出 null 等于没启用。 +func TestWebSearchSpec_ToOpenAIOptions_EmptyIsEnabled(t *testing.T) { + opts := (&WebSearchSpec{}).ToOpenAIOptions() + if string(opts) != `{}` { + t.Errorf("空 spec 应产出 {}, got %s", opts) + } + if DetectWebSearchFromOpenAI(opts) == nil { + t.Error("产出的 options 应能被自身探测器识别为启用") + } + // nil 接收者产出 nil(调用方据此判断不注入字段) + var nilSpec *WebSearchSpec + if nilSpec.ToOpenAIOptions() != nil { + t.Error("nil spec 应产出 nil") + } +} diff --git a/relaykit/relayconvert/reasoning/suffix.go b/relaykit/relayconvert/reasoning/suffix.go deleted file mode 100644 index 49f1c7a..0000000 --- a/relaykit/relayconvert/reasoning/suffix.go +++ /dev/null @@ -1,51 +0,0 @@ -package reasoning - -import ( - "strings" - - "github.com/samber/lo" -) - -var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"} - -var OpenAIEffortSuffixes = []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"} - -var DeepSeekV4EffortSuffixes = []string{"-none", "-max"} - -// TrimEffortSuffix 返回 (去除后缀的 modelName, effort 等级如 "low", 是否匹配到后缀)。 -func TrimEffortSuffix(modelName string) (string, string, bool) { - return TrimEffortSuffixWithSuffixes(modelName, EffortSuffixes) -} - -func TrimEffortSuffixWithSuffixes(modelName string, suffixes []string) (string, string, bool) { - suffix, found := lo.Find(suffixes, func(s string) bool { - return strings.HasSuffix(modelName, s) - }) - if !found { - return modelName, "", false - } - return strings.TrimSuffix(modelName, suffix), strings.TrimPrefix(suffix, "-"), true -} - -func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) { - baseModel, effort, ok := TrimEffortSuffixWithSuffixes(modelName, OpenAIEffortSuffixes) - if !ok { - return "", modelName - } - return effort, baseModel -} - -func ParseDeepSeekV4ThinkingSuffix(modelName string) (baseModel string, thinkingType string, effort string, ok bool) { - baseModel, suffix, ok := TrimEffortSuffixWithSuffixes(modelName, DeepSeekV4EffortSuffixes) - if !ok || !strings.HasPrefix(baseModel, "deepseek-v4-") { - return modelName, "", "", false - } - switch suffix { - case "none": - return baseModel, "disabled", "", true - case "max": - return baseModel, "enabled", "max", true - default: - return modelName, "", "", false - } -} diff --git a/relaykit/relayconvert/reasoning/suffix_test.go b/relaykit/relayconvert/reasoning/suffix_test.go deleted file mode 100644 index 3a36082..0000000 --- a/relaykit/relayconvert/reasoning/suffix_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package reasoning - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTrimEffortSuffix(t *testing.T) { - cases := []struct { - model string - wantBase string - wantEffort string - wantFound bool - }{ - {"gpt-low", "gpt", "low", true}, - {"gpt-medium", "gpt", "medium", true}, - {"gpt-high", "gpt", "high", true}, - {"gpt-xhigh", "gpt", "xhigh", true}, - {"gpt-max", "gpt", "max", true}, - {"gpt-minimal", "gpt", "minimal", true}, - {"gpt", "gpt", "", false}, - } - for _, c := range cases { - base, effort, ok := TrimEffortSuffix(c.model) - assert.Equal(t, c.wantBase, base, "model=%q base", c.model) - assert.Equal(t, c.wantEffort, effort, "model=%q effort", c.model) - assert.Equal(t, c.wantFound, ok, "model=%q found", c.model) - } -} - -func TestTrimEffortSuffixWithSuffixes(t *testing.T) { - base, effort, ok := TrimEffortSuffixWithSuffixes("m-custom", []string{"-custom"}) - assert.True(t, ok) - assert.Equal(t, "m", base) - assert.Equal(t, "custom", effort) - - // 无匹配 - base, _, ok = TrimEffortSuffixWithSuffixes("m", []string{"-custom"}) - assert.False(t, ok) - assert.Equal(t, "m", base) -} - -func TestParseOpenAIReasoningEffortFromModelSuffix(t *testing.T) { - effort, base := ParseOpenAIReasoningEffortFromModelSuffix("o3-high") - assert.Equal(t, "high", effort) - assert.Equal(t, "o3", base) - - effort, base = ParseOpenAIReasoningEffortFromModelSuffix("o3-none") - assert.Equal(t, "none", effort) - assert.Equal(t, "o3", base) - - // 无后缀:effort 为空,baseModel 为原名 - effort, base = ParseOpenAIReasoningEffortFromModelSuffix("o3") - assert.Equal(t, "", effort) - assert.Equal(t, "o3", base) -} - -func TestParseDeepSeekV4ThinkingSuffix(t *testing.T) { - // 合法:deepseek-v4--max / -none - base, thinking, effort, ok := ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat-max") - assert.True(t, ok) - assert.Equal(t, "deepseek-v4-chat", base) - assert.Equal(t, "enabled", thinking) - assert.Equal(t, "max", effort) - - base, thinking, effort, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat-none") - assert.True(t, ok) - assert.Equal(t, "deepseek-v4-chat", base) - assert.Equal(t, "disabled", thinking) - assert.Equal(t, "", effort) - - // 无第二段(deepseek-v4-max):baseModel 缺少 "deepseek-v4-" 前缀 → false - _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-max") - assert.False(t, ok) - - // 非 deepseek 前缀 - _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("other-model-max") - assert.False(t, ok) - - // 无后缀 - _, _, _, ok = ParseDeepSeekV4ThinkingSuffix("deepseek-v4-chat") - assert.False(t, ok) -} diff --git a/relaykit/relayconvert/register/capability_invariant_test.go b/relaykit/relayconvert/register/capability_invariant_test.go new file mode 100644 index 0000000..514fbeb --- /dev/null +++ b/relaykit/relayconvert/register/capability_invariant_test.go @@ -0,0 +1,978 @@ +package register + +// capability_invariant_test.go — 能力守恒不变量测试。 +// +// 目的:防止协议转换在某个方向上「静默丢能力」(工具定义丢失、联网搜索被吞、 +// 图片被丢、多轮工具循环断链、系统提示消失等)。与金样本(精确字节比对)不同, +// 本测试断言的是结构性不变量:一份「满配」入站请求经每个已注册方向转换后, +// 目标格式请求中各能力构件的数量与标识必须符合该方向的期望档案。 +// +// 关键设计: +// - 输入用 wire JSON 并以宿主 parseInboundRequest 同款方式(裸 json.Unmarshal +// 进 DTO)解码——转换器收到的形状与线上完全一致(如 OpenAI 多模态 content +// 实际是 []any 而非 []dto.ContentPart); +// - 断言在转换结果 marshal 回 JSON 之后进行——检查的是上游真正收到的字节形状; +// - 期望档案按转换器 ID 显式登记(capExpectations)。测试枚举注册表全部请求 +// 转换器,未登记期望的方向直接失败并打印实测档案——新增方向必须同步声明 +// 其能力预期,堵住「矩阵接管但能力静默丢失」的盲区; +// - 已知的合理能力缺口(目标格式无法表达)以注释形式写在期望条目上, +// 改动导致缺口扩大时测试失败。 + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" + "github.com/qianfree/team-api/relaykit/types" + "github.com/stretchr/testify/require" +) + +// ---------- 满配入站请求(wire JSON,形状与真实客户端一致) ---------- + +// 各 marker 全局唯一,用于跨格式检查文本是否幸存。 +const ( + mkSystem = "CAP_MARKER_SYSTEM" + mkUser = "CAP_MARKER_USER" + mkToolResult = "CAP_MARKER_TOOL_RESULT" + mkFollowup = "CAP_MARKER_FOLLOWUP" + imgDataURL = "data:image/jpeg;base64,/9j/4AAQSkZJRg==" +) + +var fullWireRequests = map[types.RelayFormat]string{ + types.RelayFormatOpenAI: `{ + "model": "origin-model", + "messages": [ + {"role": "system", "content": "` + mkSystem + ` you are helpful"}, + {"role": "user", "content": [ + {"type": "text", "text": "` + mkUser + ` describe this image"}, + {"type": "image_url", "image_url": {"url": "` + imgDataURL + `"}} + ]}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "` + mkToolResult + ` 68F"}, + {"role": "user", "content": "` + mkFollowup + ` thanks"} + ], + "tools": [ + {"type": "function", "function": {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}} + ], + "tool_choice": {"type": "function", "function": {"name": "get_weather"}}, + "max_tokens": 1024, + "stop": ["END_TOKEN"], + "temperature": 0.7, + "stream": true + }`, + types.RelayFormatClaude: `{ + "model": "origin-model", + "max_tokens": 1024, + "system": "` + mkSystem + ` you are helpful", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "` + mkUser + ` describe this image"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "checking weather"}, + {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "` + mkToolResult + ` 68F"} + ]}, + {"role": "user", "content": "` + mkFollowup + ` thanks"} + ], + "tools": [ + {"name": "get_weather", "description": "get weather", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"name": "lookup_stock", "description": "stock quote", "input_schema": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ], + "tool_choice": {"type": "tool", "name": "get_weather"}, + "stop_sequences": ["END_TOKEN"], + "temperature": 0.7, + "stream": true + }`, + types.RelayFormatGemini: `{ + "systemInstruction": {"parts": [{"text": "` + mkSystem + ` you are helpful"}]}, + "contents": [ + {"role": "user", "parts": [ + {"text": "` + mkUser + ` describe this image"}, + {"inlineData": {"mimeType": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "model", "parts": [ + {"text": "checking weather"}, + {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}} + ]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "get_weather", "response": {"result": "` + mkToolResult + ` 68F"}}} + ]}, + {"role": "user", "parts": [{"text": "` + mkFollowup + ` thanks"}]} + ], + "tools": [ + {"functionDeclarations": [ + {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ]} + ], + "toolConfig": {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["get_weather"]}}, + "generationConfig": {"maxOutputTokens": 1024, "stopSequences": ["END_TOKEN"], "temperature": 0.7} + }`, + types.RelayFormatOpenAIResponses: `{ + "model": "origin-model", + "instructions": "` + mkSystem + ` you are helpful", + "input": [ + {"role": "user", "content": [ + {"type": "input_text", "text": "` + mkUser + ` describe this image"}, + {"type": "input_image", "image_url": "` + imgDataURL + `"} + ]}, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "` + mkToolResult + ` 68F"}, + {"role": "user", "content": [{"type": "input_text", "text": "` + mkFollowup + ` thanks"}]} + ], + "tools": [ + {"type": "function", "name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"type": "function", "name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ], + "tool_choice": {"type": "function", "name": "get_weather"}, + "max_output_tokens": 1024, + "temperature": 0.7, + "stream": true + }`, +} + +// parseWireRequest 与宿主 relay/handler/relaykit_bridge.go parseInboundRequest 同款: +// 裸 json.Unmarshal 进对应 DTO 指针,保证转换器收到的形状与线上一致。 +func parseWireRequest(t *testing.T, format types.RelayFormat, body string) any { + t.Helper() + switch format { + case types.RelayFormatOpenAI: + var req dto.GeneralOpenAIRequest + require.NoError(t, json.Unmarshal([]byte(body), &req)) + return &req + case types.RelayFormatClaude: + var req dto.ClaudeRequest + require.NoError(t, json.Unmarshal([]byte(body), &req)) + return &req + case types.RelayFormatGemini: + var req dto.GeminiChatRequest + require.NoError(t, json.Unmarshal([]byte(body), &req)) + return &req + case types.RelayFormatOpenAIResponses: + var req dto.OpenAIResponsesRequest + require.NoError(t, json.Unmarshal([]byte(body), &req)) + return &req + } + t.Fatalf("未支持的入站格式: %s", format) + return nil +} + +// ---------- 能力画像与提取器 ---------- + +// capProfile 是从某格式请求 JSON 中提取出的标准化能力画像。 +type capProfile struct { + ToolNames []string // 自定义函数工具名(升序) + ForcedToolName string // 强制 tool_choice 指向的函数名(无法表达/未强制为空) + HasWebSearch bool // 目标格式的原生服务端搜索构件是否存在 + ImageCount int // 图片内容块数量 + ToolCallCount int // 模型侧函数调用数量(多轮历史中的 assistant tool_call) + ToolResultCount int // 工具结果块数量 + StopCount int // stop 序列数量 + MaxTokensSet bool // 输出上限是否已表达 + SystemMarker bool // 系统提示 marker 是否幸存(不限承载位置) + UserMarker bool // 首条用户文本 marker 是否幸存 + ToolResultText bool // 工具结果文本 marker 是否幸存 + FollowupMarker bool // 多轮后续用户消息 marker 是否幸存 +} + +// extractProfile 将转换结果 marshal 回 JSON 后按目标格式提取能力画像。 +func extractProfile(t *testing.T, target types.RelayFormat, converted any) capProfile { + t.Helper() + raw, err := json.Marshal(converted) + require.NoError(t, err, "marshal 转换结果") + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + + var p capProfile + body := string(raw) + p.SystemMarker = strings.Contains(body, mkSystem) + p.UserMarker = strings.Contains(body, mkUser) + p.ToolResultText = strings.Contains(body, mkToolResult) + p.FollowupMarker = strings.Contains(body, mkFollowup) + + switch target { + case types.RelayFormatOpenAI: + extractOpenAIStructure(doc, &p) + case types.RelayFormatClaude: + extractClaudeStructure(doc, &p) + case types.RelayFormatGemini: + extractGeminiStructure(doc, &p) + case types.RelayFormatOpenAIResponses: + extractResponsesStructure(doc, &p) + case types.RelayFormatOllama: + extractOllamaStructure(doc, &p) + default: + t.Fatalf("未支持的目标格式: %s", target) + } + sort.Strings(p.ToolNames) + return p +} + +// JSON 取值助手 +func asMap(v any) map[string]any { m, _ := v.(map[string]any); return m } +func asArr(v any) []any { a, _ := v.([]any); return a } +func asStr(v any) string { s, _ := v.(string); return s } +func stopLen(v any) int { + switch s := v.(type) { + case string: + if s != "" { + return 1 + } + case []any: + return len(s) + } + return 0 +} + +func extractOpenAIStructure(doc map[string]any, p *capProfile) { + for _, tl := range asArr(doc["tools"]) { + tm := asMap(tl) + if asStr(tm["type"]) == "function" { + if name := asStr(asMap(tm["function"])["name"]); name != "" { + p.ToolNames = append(p.ToolNames, name) + } + } + } + if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "function" { + p.ForcedToolName = asStr(asMap(tc["function"])["name"]) + } + p.HasWebSearch = doc["web_search_options"] != nil + for _, msg := range asArr(doc["messages"]) { + mm := asMap(msg) + for _, part := range asArr(mm["content"]) { + if asStr(asMap(part)["type"]) == "image_url" { + p.ImageCount++ + } + } + p.ToolCallCount += len(asArr(mm["tool_calls"])) + if asStr(mm["role"]) == "tool" { + p.ToolResultCount++ + } + } + p.StopCount = stopLen(doc["stop"]) + p.MaxTokensSet = doc["max_tokens"] != nil || doc["max_completion_tokens"] != nil +} + +func extractClaudeStructure(doc map[string]any, p *capProfile) { + for _, tl := range asArr(doc["tools"]) { + tm := asMap(tl) + typ := asStr(tm["type"]) + if strings.HasPrefix(typ, "web_search") { + p.HasWebSearch = true + continue + } + if typ == "" || typ == "custom" { + if name := asStr(tm["name"]); name != "" { + p.ToolNames = append(p.ToolNames, name) + } + } + } + if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "tool" { + p.ForcedToolName = asStr(tc["name"]) + } + for _, msg := range asArr(doc["messages"]) { + for _, blk := range asArr(asMap(msg)["content"]) { + switch asStr(asMap(blk)["type"]) { + case "image": + p.ImageCount++ + case "tool_use": + p.ToolCallCount++ + case "tool_result": + p.ToolResultCount++ + } + } + } + p.StopCount = len(asArr(doc["stop_sequences"])) + p.MaxTokensSet = doc["max_tokens"] != nil +} + +func extractGeminiStructure(doc map[string]any, p *capProfile) { + for _, tl := range asArr(doc["tools"]) { + tm := asMap(tl) + if tm["googleSearch"] != nil || tm["googleSearchRetrieval"] != nil { + p.HasWebSearch = true + } + for _, fd := range asArr(tm["functionDeclarations"]) { + if name := asStr(asMap(fd)["name"]); name != "" { + p.ToolNames = append(p.ToolNames, name) + } + } + } + if fcc := asMap(asMap(doc["toolConfig"])["functionCallingConfig"]); fcc != nil { + if asStr(fcc["mode"]) == "ANY" { + if names := asArr(fcc["allowedFunctionNames"]); len(names) == 1 { + p.ForcedToolName = asStr(names[0]) + } + } + } + for _, content := range asArr(doc["contents"]) { + for _, part := range asArr(asMap(content)["parts"]) { + pm := asMap(part) + if pm["inlineData"] != nil { + p.ImageCount++ + } + if pm["functionCall"] != nil { + p.ToolCallCount++ + } + if pm["functionResponse"] != nil { + p.ToolResultCount++ + } + } + } + gc := asMap(doc["generationConfig"]) + p.StopCount = len(asArr(gc["stopSequences"])) + p.MaxTokensSet = gc["maxOutputTokens"] != nil +} + +func extractResponsesStructure(doc map[string]any, p *capProfile) { + for _, tl := range asArr(doc["tools"]) { + tm := asMap(tl) + typ := asStr(tm["type"]) + if strings.HasPrefix(typ, "web_search") { + p.HasWebSearch = true + continue + } + if typ == "function" { + if name := asStr(tm["name"]); name != "" { + p.ToolNames = append(p.ToolNames, name) + } + } + } + if tc := asMap(doc["tool_choice"]); tc != nil && asStr(tc["type"]) == "function" { + p.ForcedToolName = asStr(tc["name"]) + } + for _, item := range asArr(doc["input"]) { + im := asMap(item) + switch asStr(im["type"]) { + case "function_call": + p.ToolCallCount++ + case "function_call_output": + p.ToolResultCount++ + } + for _, part := range asArr(im["content"]) { + if asStr(asMap(part)["type"]) == "input_image" { + p.ImageCount++ + } + } + } + p.MaxTokensSet = doc["max_output_tokens"] != nil + // Responses API 无请求级 stop 序列,StopCount 恒 0 +} + +func extractOllamaStructure(doc map[string]any, p *capProfile) { + for _, tl := range asArr(doc["tools"]) { + tm := asMap(tl) + if asStr(tm["type"]) == "function" { + if name := asStr(asMap(tm["function"])["name"]); name != "" { + p.ToolNames = append(p.ToolNames, name) + } + } + } + for _, msg := range asArr(doc["messages"]) { + mm := asMap(msg) + p.ImageCount += len(asArr(mm["images"])) + p.ToolCallCount += len(asArr(mm["tool_calls"])) + if asStr(mm["role"]) == "tool" { + p.ToolResultCount++ + } + } + opts := asMap(doc["options"]) + p.StopCount = stopLen(opts["stop"]) + p.MaxTokensSet = opts["num_predict"] != nil + // Ollama 无 tool_choice / 服务端搜索 +} + +// ---------- 期望档案表 ---------- + +// capMeta 构造转换所需 Meta(与宿主构造方式对齐:模型名 + 渠道选项)。 +func capMeta() *convmeta.Values { + return &convmeta.Values{ + ChannelMetaAttached: true, + OriginModelName: "origin-model", + UpstreamModelName: "upstream-model", + Options: &convmeta.Options{ + Claude: convmeta.ClaudeOptions{DefaultMaxTokens: func(string) int { return 4096 }}, + Gemini: convmeta.GeminiOptions{WebSearchToGoogleSearch: true}, + }, + } +} + +// capExpectations 每个已注册请求转换器在满配请求下的期望能力画像。 +// 新注册方向必须在此登记(TestCapabilityInvariants_Full 枚举注册表并强制检查), +// 已知合理缺口必须注释说明原因。 +var capExpectations = map[string]capProfile{ + // 满配画像基准:两个自定义工具 + 强制工具选择 + 1 图片 + 1 轮工具循环 + stop + max_tokens + 全部文本 marker + relayconvert.ConverterOpenAIChatToClaudeMessages: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterOpenAIChatToGeminiContent: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterOpenAIChatToOllama: { + // 缺口:Ollama /api/chat 无 tool_choice 语义,强制工具选择无法表达 + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterOpenAIChatToOpenAIResponses: { + // 缺口:Responses API 无请求级 stop 参数 + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterClaudeMessagesToOpenAIChat: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterClaudeMessagesToGeminiContent: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterGeminiContentToOpenAIChat: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterGeminiContentToClaudeMessages: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 1, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterOpenAIResponsesToOpenAIChat: { + // StopCount 0:Responses 入站请求本身无 stop 概念(非转换丢失) + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterResponsesToClaudeMessages: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, + relayconvert.ConverterOpenAIResponsesToGemini: { + ToolNames: []string{"get_weather", "lookup_stock"}, ForcedToolName: "get_weather", + ImageCount: 1, ToolCallCount: 1, ToolResultCount: 1, StopCount: 0, MaxTokensSet: true, + SystemMarker: true, UserMarker: true, ToolResultText: true, FollowupMarker: true, + }, +} + +func TestCapabilityInvariants_Full(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListRequestConverterIDs() { + spec, ok := relayconvert.LookupRequestConverter(id) + require.True(t, ok) + + input, hasInput := fullWireRequests[spec.From] + if !hasInput { + t.Errorf("转换器 %s 的入站格式 %s 没有满配请求语料,请在 fullWireRequests 补充", id, spec.From) + continue + } + + t.Run(id, func(t *testing.T) { + parsed := parseWireRequest(t, spec.From, input) + converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed) + require.NoError(t, err, "转换失败") + + actual := extractProfile(t, spec.To, converted) + + expected, registered := capExpectations[id] + if !registered { + t.Errorf("转换器 %s 未登记能力期望档案。实测档案(确认合理后粘贴进 capExpectations):\n%s: %s,", + id, fmt.Sprintf("%q", id), profileLiteral(actual)) + return + } + require.Equal(t, expected, actual, "能力画像与期望不符(若为有意变更,请同步更新 capExpectations 并说明缺口原因)") + }) + } +} + +// profileLiteral 输出可直接粘贴进期望表的 Go 字面量。 +func profileLiteral(p capProfile) string { + return fmt.Sprintf("{ToolNames: %#v, ForcedToolName: %q, HasWebSearch: %v, ImageCount: %d, ToolCallCount: %d, ToolResultCount: %d, StopCount: %d, MaxTokensSet: %v, SystemMarker: %v, UserMarker: %v, ToolResultText: %v, FollowupMarker: %v}", + p.ToolNames, p.ForcedToolName, p.HasWebSearch, p.ImageCount, p.ToolCallCount, p.ToolResultCount, p.StopCount, p.MaxTokensSet, p.SystemMarker, p.UserMarker, p.ToolResultText, p.FollowupMarker) +} + +// ---------- 响应侧能力守恒 ---------- + +// 响应侧 marker +const ( + mkAssistant = "CAP_MARKER_ASSISTANT_TEXT" + mkThinking = "CAP_MARKER_THINKING" +) + +// upstreamWireResponses 各上游格式的满配非流式响应(wire JSON): +// assistant 文本 + thinking(格式支持时)+ 1 个函数调用 + usage。 +var upstreamWireResponses = map[types.RelayFormat]string{ + types.RelayFormatOpenAI: `{ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "created": 1741569952, + "model": "upstream-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "` + mkAssistant + ` the weather is 68F", + "reasoning_content": "` + mkThinking + ` user asks weather", + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}}] + }, + "finish_reason": "tool_calls" + }], + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150} + }`, + types.RelayFormatClaude: `{ + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "model": "upstream-model", + "content": [ + {"type": "thinking", "thinking": "` + mkThinking + ` user asks weather", "signature": "sig_abc"}, + {"type": "text", "text": "` + mkAssistant + ` the weather is 68F"}, + {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}} + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 100, "output_tokens": 50} + }`, + types.RelayFormatGemini: `{ + "candidates": [{ + "content": {"role": "model", "parts": [ + {"text": "` + mkThinking + ` user asks weather", "thought": true}, + {"text": "` + mkAssistant + ` the weather is 68F", "thoughtSignature": "sig_abc"}, + {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}} + ]}, + "finishReason": "STOP", + "index": 0 + }], + "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 50, "totalTokenCount": 150}, + "modelVersion": "upstream-model" + }`, + types.RelayFormatOpenAIResponses: `{ + "id": "resp_abc123", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "upstream-model", + "output": [ + {"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "` + mkThinking + ` user asks weather"}]}, + {"type": "message", "id": "msg_1", "status": "completed", "role": "assistant", "content": [{"type": "output_text", "text": "` + mkAssistant + ` the weather is 68F", "annotations": []}]}, + {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}", "status": "completed"} + ], + "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} + }`, + types.RelayFormatOllama: `{ + "model": "upstream-model", + "created_at": "2026-01-01T00:00:00Z", + "message": { + "role": "assistant", + "content": "` + mkAssistant + ` the weather is 68F", + "thinking": "` + mkThinking + ` user asks weather", + "tool_calls": [{"function": {"name": "get_weather", "arguments": {"location": "Boston"}}}] + }, + "done": true, + "done_reason": "stop", + "prompt_eval_count": 100, + "eval_count": 50 + }`, +} + +// parseWireResponse 按上游格式把响应体解析为对应 DTO 指针(与宿主响应解析一致)。 +func parseWireResponse(t *testing.T, format types.RelayFormat, body string) any { + t.Helper() + switch format { + case types.RelayFormatOpenAI: + var resp dto.ChatCompletionResponse + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + return &resp + case types.RelayFormatClaude: + var resp dto.ClaudeResponse + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + return &resp + case types.RelayFormatGemini: + var resp dto.GeminiChatResponse + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + return &resp + case types.RelayFormatOpenAIResponses: + var resp dto.OpenAIResponsesResponse + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + return &resp + case types.RelayFormatOllama: + var resp dto.OllamaChatResponse + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + return &resp + } + t.Fatalf("未支持的上游格式: %s", format) + return nil +} + +// respProfile 客户端格式响应的能力画像。 +type respProfile struct { + ToolCallNames []string // 函数调用名(升序) + TextMarker bool // assistant 文本 marker 幸存 + ThinkMarker bool // thinking 内容 marker 幸存(承载位置不限) + UsageIn bool // 输入 token 数 > 0 + UsageOut bool // 输出 token 数 > 0 +} + +func extractRespProfile(t *testing.T, client types.RelayFormat, converted any) respProfile { + t.Helper() + raw, err := json.Marshal(converted) + require.NoError(t, err) + var doc map[string]any + require.NoError(t, json.Unmarshal(raw, &doc)) + + var p respProfile + body := string(raw) + p.TextMarker = strings.Contains(body, mkAssistant) + p.ThinkMarker = strings.Contains(body, mkThinking) + + num := func(m map[string]any, key string) float64 { f, _ := m[key].(float64); return f } + + switch client { + case types.RelayFormatOpenAI: + for _, ch := range asArr(doc["choices"]) { + msg := asMap(asMap(ch)["message"]) + for _, tc := range asArr(msg["tool_calls"]) { + if name := asStr(asMap(asMap(tc)["function"])["name"]); name != "" { + p.ToolCallNames = append(p.ToolCallNames, name) + } + } + } + usage := asMap(doc["usage"]) + p.UsageIn = num(usage, "prompt_tokens") > 0 + p.UsageOut = num(usage, "completion_tokens") > 0 + case types.RelayFormatClaude: + for _, blk := range asArr(doc["content"]) { + bm := asMap(blk) + if asStr(bm["type"]) == "tool_use" { + if name := asStr(bm["name"]); name != "" { + p.ToolCallNames = append(p.ToolCallNames, name) + } + } + } + usage := asMap(doc["usage"]) + p.UsageIn = num(usage, "input_tokens") > 0 + p.UsageOut = num(usage, "output_tokens") > 0 + case types.RelayFormatGemini: + for _, cand := range asArr(doc["candidates"]) { + for _, part := range asArr(asMap(asMap(cand)["content"])["parts"]) { + if fc := asMap(asMap(part)["functionCall"]); fc != nil { + if name := asStr(fc["name"]); name != "" { + p.ToolCallNames = append(p.ToolCallNames, name) + } + } + } + } + usage := asMap(doc["usageMetadata"]) + p.UsageIn = num(usage, "promptTokenCount") > 0 + p.UsageOut = num(usage, "candidatesTokenCount") > 0 + case types.RelayFormatOpenAIResponses: + for _, item := range asArr(doc["output"]) { + im := asMap(item) + if asStr(im["type"]) == "function_call" { + if name := asStr(im["name"]); name != "" { + p.ToolCallNames = append(p.ToolCallNames, name) + } + } + } + usage := asMap(doc["usage"]) + p.UsageIn = num(usage, "input_tokens") > 0 + p.UsageOut = num(usage, "output_tokens") > 0 + default: + t.Fatalf("未支持的客户端格式: %s", client) + } + sort.Strings(p.ToolCallNames) + return p +} + +// respExpectations 各方向(响应转换:上游 To 格式 → 客户端 From 格式)的期望画像。 +// 新注册方向必须在此登记;已知缺口注明原因,修复后同步翻转期望值。 +var respExpectations = map[string]respProfile{ + relayconvert.ConverterOpenAIChatToClaudeMessages: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterOpenAIChatToGeminiContent: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterOpenAIChatToOllama: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterOpenAIChatToOpenAIResponses: { + // Responses 上游的 reasoning summary 回填 chat 的 reasoning_content + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterClaudeMessagesToOpenAIChat: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterClaudeMessagesToGeminiContent: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterGeminiContentToOpenAIChat: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterGeminiContentToClaudeMessages: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + // Responses 客户端方向:上游 thinking(chat reasoning_content / Claude thinking 块 / + // Gemini thought part)合成为 Responses 的 reasoning 输出项 + //({type:"reasoning", summary:[{type:"summary_text", text:...}]},排在 message 之前) + relayconvert.ConverterOpenAIResponsesToOpenAIChat: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterResponsesToClaudeMessages: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, + relayconvert.ConverterOpenAIResponsesToGemini: { + ToolCallNames: []string{"get_weather"}, TextMarker: true, ThinkMarker: true, UsageIn: true, UsageOut: true, + }, +} + +func TestCapabilityInvariants_Response(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListResponseConverterIDs() { + spec, ok := relayconvert.LookupResponseConverter(id) + require.True(t, ok) + if spec.Convert == nil { + continue // 纯流式登记(无非流式转换器)不在本测试范围 + } + + input, hasInput := upstreamWireResponses[spec.To] + if !hasInput { + t.Errorf("响应转换器 %s 的上游格式 %s 没有响应语料,请在 upstreamWireResponses 补充", id, spec.To) + continue + } + + t.Run(id, func(t *testing.T) { + parsed := parseWireResponse(t, spec.To, input) + converted, _, err := spec.Convert(ctx, capMeta(), parsed) + require.NoError(t, err, "响应转换失败") + + actual := extractRespProfile(t, spec.From, converted) + + expected, registered := respExpectations[id] + if !registered { + t.Errorf("响应转换器 %s 未登记期望档案。实测:\n%q: {ToolCallNames: %#v, TextMarker: %v, ThinkMarker: %v, UsageIn: %v, UsageOut: %v},", + id, id, actual.ToolCallNames, actual.TextMarker, actual.ThinkMarker, actual.UsageIn, actual.UsageOut) + return + } + require.Equal(t, expected, actual, "响应能力画像与期望不符(若为有意变更请更新 respExpectations 并注明原因)") + }) + } +} + +// ---------- 联网搜索场景 ---------- + +// webSearchWireRequests 各入站格式的「仅服务端搜索工具」请求(无自定义函数—— +// Claude→Gemini 的 googleSearch 映射采取保守策略,与 functionDeclarations 共存时不附加)。 +var webSearchWireRequests = map[types.RelayFormat]string{ + types.RelayFormatOpenAI: `{ + "model": "origin-model", + "messages": [{"role": "user", "content": "` + mkUser + ` search the latest news"}], + "web_search_options": {}, + "max_tokens": 1024 + }`, + types.RelayFormatClaude: `{ + "model": "origin-model", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "` + mkUser + ` search the latest news"}], + "tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}] + }`, + types.RelayFormatGemini: `{ + "contents": [{"role": "user", "parts": [{"text": "` + mkUser + ` search the latest news"}]}], + "tools": [{"googleSearch": {}}], + "generationConfig": {"maxOutputTokens": 1024} + }`, + types.RelayFormatOpenAIResponses: `{ + "model": "origin-model", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "` + mkUser + ` search the latest news"}]}], + "tools": [{"type": "web_search"}], + "max_output_tokens": 1024 + }`, +} + +// webSearchExpectations 各方向转换后目标格式是否保留了服务端搜索能力。 +// +// 这是用户最担心的静默丢失能力之一:客户端明确要求联网、请求却以「没搜索」的方式 +// 成功返回,比直接报错更难排查。四种文本协议都有原生构件(chat 的 web_search_options、 +// Claude 的 web_search_* 工具、Gemini 的 googleSearch、Responses 的 web_search 工具), +// 由 shared/websearch.go 的 WebSearchSpec 做跨协议中间表示;chat 作为两跳链的中枢 +// 承载该能力,因此跨原生方向(Gemini→Claude 等)也能保住。 +// +// 为 false 的方向必须是**目标协议真实无此能力**,并注明原因;单纯「没实现」不得置 false。 +var webSearchExpectations = map[string]bool{ + // OpenAI chat 入站 web_search_options + relayconvert.ConverterOpenAIChatToClaudeMessages: true, // → Claude web_search 内置工具 + relayconvert.ConverterOpenAIChatToGeminiContent: true, // → googleSearch(渠道开启 WebSearchToGoogleSearch 时;本测试开启) + relayconvert.ConverterOpenAIChatToOllama: false, // Ollama 无服务端搜索能力(协议真实缺失) + relayconvert.ConverterOpenAIChatToOpenAIResponses: true, // → Responses web_search 工具 + + // Claude 入站 web_search_* 工具 + relayconvert.ConverterClaudeMessagesToOpenAIChat: true, // → chat web_search_options + relayconvert.ConverterClaudeMessagesToGeminiContent: true, // → googleSearch(直连方向,渠道开关控制) + + // Gemini 入站 googleSearch 工具 + relayconvert.ConverterGeminiContentToOpenAIChat: true, // → chat web_search_options + relayconvert.ConverterGeminiContentToClaudeMessages: true, // 两跳链 g2o→o2c,经中枢 web_search_options 保住 + + // Responses 入站 web_search 工具 + relayconvert.ConverterOpenAIResponsesToOpenAIChat: true, // → chat web_search_options + relayconvert.ConverterResponsesToClaudeMessages: true, // 两跳链 r2o→o2c + relayconvert.ConverterOpenAIResponsesToGemini: true, // 两跳链 r2o→o2g +} + +func TestCapabilityInvariants_WebSearch(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListRequestConverterIDs() { + spec, ok := relayconvert.LookupRequestConverter(id) + require.True(t, ok) + + input, hasInput := webSearchWireRequests[spec.From] + if !hasInput { + t.Errorf("转换器 %s 的入站格式 %s 没有搜索场景语料,请在 webSearchWireRequests 补充", id, spec.From) + continue + } + + t.Run(id, func(t *testing.T) { + parsed := parseWireRequest(t, spec.From, input) + converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed) + require.NoError(t, err, "转换失败") + + actual := extractProfile(t, spec.To, converted) + + expected, registered := webSearchExpectations[id] + if !registered { + t.Errorf("转换器 %s 未登记搜索能力期望(实测 HasWebSearch=%v),请在 webSearchExpectations 补充", id, actual.HasWebSearch) + return + } + require.Equal(t, expected, actual.HasWebSearch, + "服务端搜索能力保留情况与期望不符(若为有意变更请更新 webSearchExpectations 并注明原因)") + // 搜索场景无自定义函数:不得凭空多出函数工具(如把 web_search 错当自定义函数转换) + require.Empty(t, actual.ToolNames, "搜索场景不应产出自定义函数工具") + // 用户文本必须幸存 + require.True(t, actual.UserMarker, "用户文本在搜索场景转换后丢失") + }) + } +} + +// ---------- 响应侧 grounding 还原 ---------- + +// geminiGroundedResponse 带 groundingMetadata 的 Gemini 上游响应(满配搜索场景): +// 模型真的去搜了,回答附带两条网页来源与一个覆盖片段。 +const geminiGroundedResponse = `{ + "candidates": [{ + "content": {"role": "model", "parts": [ + {"text": "` + mkAssistant + ` the weather is 68F"} + ]}, + "finishReason": "STOP", + "index": 0, + "groundingMetadata": { + "webSearchQueries": ["boston weather"], + "groundingChunks": [ + {"web": {"uri": "https://weather.example/boston", "title": "weather.example"}}, + {"web": {"uri": "https://forecast.example/ma", "title": "forecast.example"}} + ], + "groundingSupports": [{ + "segment": {"startIndex": 0, "endIndex": 5, "text": "` + mkAssistant + `"}, + "groundingChunkIndices": [0, 1] + }] + } + }], + "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 50, "totalTokenCount": 150}, + "modelVersion": "upstream-model" +}` + +// groundingRestoreExpectations 各客户端方向是否把上游的搜索来源还原为本协议的引用构件。 +// +// 与请求侧(webSearchExpectations)对称:请求侧保住「要求联网」的能力, +// 响应侧保住「搜到了什么」的证据。只还原请求、不还原响应,客户端拿到的是一段 +// 被搜索增强却无出处的文本——看不到来源、无法核查,联网搜索的主要价值随之丢失。 +// +// 键为**响应转换方向的配对转换器 ID**(From=客户端格式、To=Gemini 上游)。 +var groundingRestoreExpectations = map[string]bool{ + relayconvert.ConverterOpenAIChatToGeminiContent: true, // → chat message.annotations(url_citation) + relayconvert.ConverterClaudeMessagesToGeminiContent: true, // → server_tool_use + web_search_tool_result 块 + relayconvert.ConverterOpenAIResponsesToGemini: true, // → web_search_call 项 + output_text.annotations +} + +// TestCapabilityInvariants_GroundingRestore 响应侧搜索证据还原: +// 上游 Gemini 返回带 groundingMetadata 的响应时,来源 URL 必须出现在客户端可见的响应里。 +func TestCapabilityInvariants_GroundingRestore(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListResponseConverterIDs() { + spec, ok := relayconvert.LookupResponseConverter(id) + require.True(t, ok) + // 只考察上游为 Gemini 的方向(grounding 是 Gemini 侧的构件) + if spec.Convert == nil || spec.To != types.RelayFormatGemini { + continue + } + + t.Run(id, func(t *testing.T) { + parsed := parseWireResponse(t, types.RelayFormatGemini, geminiGroundedResponse) + converted, _, err := spec.Convert(ctx, capMeta(), parsed) + require.NoError(t, err, "响应转换失败") + + raw, err := json.Marshal(converted) + require.NoError(t, err) + body := string(raw) + + expected, registered := groundingRestoreExpectations[id] + if !registered { + t.Errorf("响应转换器 %s 未登记 grounding 还原期望(实测含来源=%v),请在 groundingRestoreExpectations 补充", + id, strings.Contains(body, "weather.example/boston")) + return + } + + hasSource := strings.Contains(body, "https://weather.example/boston") && + strings.Contains(body, "https://forecast.example/ma") + require.Equal(t, expected, hasSource, + "搜索来源还原情况与期望不符(若为有意变更请更新 groundingRestoreExpectations 并注明原因)\n实际响应: %s", body) + + // 回答正文不得丢失(还原引用不能以吞掉正文为代价) + require.Contains(t, body, mkAssistant, "回答正文在 grounding 还原后丢失") + }) + } +} + +// TestCapabilityInvariants_GroundingSearchCountForBilling Claude 客户端方向必须把 +// 搜索次数透出到 usage.server_tool_use.web_search_requests: +// Google 在 token 之外按搜索次数单独计价,不透出则计费层无从得知发起过几次搜索。 +func TestCapabilityInvariants_GroundingSearchCountForBilling(t *testing.T) { + ctx := context.Background() + + spec, ok := relayconvert.LookupResponseConverter(relayconvert.ConverterClaudeMessagesToGeminiContent) + require.True(t, ok) + require.NotNil(t, spec.Convert) + + parsed := parseWireResponse(t, types.RelayFormatGemini, geminiGroundedResponse) + converted, _, err := spec.Convert(ctx, capMeta(), parsed) + require.NoError(t, err) + + claudeResp, ok := converted.(*dto.ClaudeResponse) + require.True(t, ok, "期望 *dto.ClaudeResponse, got %T", converted) + require.NotNil(t, claudeResp.Usage, "usage 缺失") + require.NotNil(t, claudeResp.Usage.ServerToolUse, "usage.server_tool_use 缺失(按次计费无据可依)") + require.Equal(t, 1, claudeResp.Usage.ServerToolUse.WebSearchRequests, + "web_search_requests 应等于 webSearchQueries 条数") +} diff --git a/relaykit/relayconvert/register/fuzz_test.go b/relaykit/relayconvert/register/fuzz_test.go new file mode 100644 index 0000000..159ad84 --- /dev/null +++ b/relaykit/relayconvert/register/fuzz_test.go @@ -0,0 +1,383 @@ +package register + +// fuzz_test.go — 协议转换的模糊测试(注册表级,覆盖全部已注册方向)。 +// +// 为什么需要:宿主对客户端请求体是**裸 json.Unmarshal**,解析出的 content 有 +// `[]any`(map) 与 `[]dto.ContentPart` 两种并存形态,转换器要靠 +// shared.NormalizeContentParts 归一;再加上各格式大量可选/多态字段 +//(Responses 的 action 多态、Claude 的 content 块联合体、Gemini 的 parts 联合体), +// 「结构合法但语义刁钻」的输入空间远大于手写语料能覆盖的范围。 +// +// 断言两条不变量(对任意输入都必须成立): +// 1. 不 panic——转换器崩溃会让该请求 500,且在共享进程里污染日志与指标; +// 2. 转换成功 ⇒ 结果必须可 json.Marshal——宿主紧接着就要序列化发往上游 +// (relaykit_bridge 的 marshal 失败是 hard-fail),产出不可序列化的值 +// (NaN/Inf 浮点、含 chan/func 的字段)等同于静默制造 500。 +// +// 运行方式: +// - 常规回归:go test ./relayconvert/register/ —— 只跑种子语料(含金样本全部输入); +// - 主动模糊:go test -run XXX -fuzz FuzzRequestConversion ./relayconvert/register/ +// 失败输入由 go 自动落盘到 testdata/fuzz//,随仓库提交后即成为永久回归种子。 + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/types" +) + +// fuzzFormats 模糊测试覆盖的入站格式(与宿主 parseInboundRequest 支持的集合一致)。 +var fuzzFormats = []types.RelayFormat{ + types.RelayFormatOpenAI, + types.RelayFormatClaude, + types.RelayFormatGemini, + types.RelayFormatOpenAIResponses, +} + +// fuzzParseRequest 按格式解析请求体(解析失败返回 nil,由调用方跳过—— +// 宿主对解析失败是 hard-fail 拒绝请求,不进入转换器)。 +func fuzzParseRequest(format types.RelayFormat, body []byte) any { + switch format { + case types.RelayFormatOpenAI: + var req dto.GeneralOpenAIRequest + if json.Unmarshal(body, &req) != nil { + return nil + } + return &req + case types.RelayFormatClaude: + var req dto.ClaudeRequest + if json.Unmarshal(body, &req) != nil { + return nil + } + return &req + case types.RelayFormatGemini: + var req dto.GeminiChatRequest + if json.Unmarshal(body, &req) != nil { + return nil + } + return &req + case types.RelayFormatOpenAIResponses: + var req dto.OpenAIResponsesRequest + if json.Unmarshal(body, &req) != nil { + return nil + } + return &req + } + return nil +} + +// addSeedsFromDir 把目录下匹配后缀的语料按「文件名前缀 → 格式下标」加入种子集。 +// 文件名形如 <格式>__<场景>.,与金样本套件共用同一份语料。 +func addSeedsFromDir(f *testing.F, dir, ext string) { + f.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + return // 语料目录缺失不阻断模糊测试(仍有手写种子) + } + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ext) { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + formatName := strings.SplitN(strings.TrimSuffix(e.Name(), ext), "__", 2)[0] + for i, format := range fuzzFormats { + if string(format) == formatName { + f.Add(i, string(data)) + break + } + } + } +} + +// fuzzAdversarialSeeds 手写对抗种子:结构合法但语义刁钻,专打已知易错点。 +var fuzzAdversarialSeeds = []struct { + formatIdx int + body string +}{ + // content 双形态:字符串 / 数组 / 空数组 / 类型错位的元素 + {0, `{"model":"m","messages":[{"role":"user","content":[]}]}`}, + {0, `{"model":"m","messages":[{"role":"user","content":[{"type":"text"}]}]}`}, + {0, `{"model":"m","messages":[{"role":"user","content":[{"type":"image_url","image_url":{}}]}]}`}, + {0, `{"model":"m","messages":[{"role":"user","content":[null,123,"x"]}]}`}, + {0, `{"model":"m","messages":[{"role":"user","content":{}}]}`}, + // 工具调用:空参数 / 非法 JSON 参数字符串 / 缺 id + {0, `{"model":"m","messages":[{"role":"assistant","tool_calls":[{"id":"","type":"function","function":{"name":"","arguments":""}}]}]}`}, + {0, `{"model":"m","messages":[{"role":"assistant","tool_calls":[{"id":"c","type":"function","function":{"name":"f","arguments":"{not json"}}]}]}`}, + {0, `{"model":"m","messages":[{"role":"tool","tool_call_id":"nonexistent","content":"r"}]}`}, + // tool_choice 多态 + {0, `{"model":"m","messages":[],"tool_choice":{"type":"function","function":{"name":"f"}}}`}, + {0, `{"model":"m","messages":[],"tool_choice":"required"}`}, + // 极端数值(NaN/Inf 无法用 JSON 表达,但超大数与负值可以) + {0, `{"model":"m","messages":[],"temperature":1e308,"max_tokens":-1,"top_p":-5}`}, + // Claude:空 content 块数组 / thinking 块 / 服务端工具 + {1, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[]}]}`}, + {1, `{"model":"m","max_tokens":1,"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"t","signature":""}]}]}`}, + {1, `{"model":"m","max_tokens":1,"messages":[],"tools":[{"type":"web_search_20250305","name":"web_search"}]}`}, + {1, `{"model":"m","max_tokens":1,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":[]}]}]}`}, + {1, `{"model":"m","max_tokens":0,"messages":[],"system":[]}`}, + // Gemini:空 parts / 多态 part / 无 role + {2, `{"contents":[{"parts":[]}]}`}, + {2, `{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"","data":""}}]}]}`}, + {2, `{"contents":[{"role":"model","parts":[{"functionCall":{"name":"","args":null}}]}]}`}, + {2, `{"contents":[],"tools":[{"googleSearch":{}}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`}, + // Responses:input 字符串形态 / 多态 action / 空 output + {3, `{"model":"m","input":"plain string input"}`}, + {3, `{"model":"m","input":[{"type":"function_call","call_id":"c","name":"f","arguments":""}]}`}, + {3, `{"model":"m","input":[{"role":"user","content":[]}],"tools":[{"type":"web_search"}]}`}, + {3, `{"model":"m","input":[],"reasoning":{"effort":"high"},"max_output_tokens":0}`}, + // 空对象与空数组(最小合法输入) + {0, `{}`}, {1, `{}`}, {2, `{}`}, {3, `{}`}, +} + +// FuzzRequestConversion 对全部已注册请求转换方向做模糊测试。 +func FuzzRequestConversion(f *testing.F) { + addSeedsFromDir(f, filepath.Join("golden", "inputs"), ".json") + for _, s := range fuzzAdversarialSeeds { + f.Add(s.formatIdx, s.body) + } + + f.Fuzz(func(t *testing.T, formatIdx int, body string) { + if formatIdx < 0 { + return + } + format := fuzzFormats[formatIdx%len(fuzzFormats)] + + parsed := fuzzParseRequest(format, []byte(body)) + if parsed == nil { + return // 解析失败:宿主在进入转换器前就已拒绝 + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for _, id := range relayconvert.ListRequestConverterIDs() { + spec, ok := relayconvert.LookupRequestConverter(id) + if !ok || spec.From != format { + continue + } + + // 不变量 1:不得 panic(panic 会直接冒泡为测试失败并记录输入) + converted, err := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed) + if err != nil { + continue // 返回错误是合法行为(宿主按 hard-fail 拒绝请求) + } + if converted == nil { + t.Fatalf("转换器 %s 返回了 (nil, nil):宿主会对 nil 做 marshal 并发出 \"null\" 请求体", id) + } + + // 不变量 2:成功的结果必须可序列化(宿主紧接着 json.Marshal 发往上游) + if _, err := json.Marshal(converted); err != nil { + t.Fatalf("转换器 %s 产出了不可序列化的结果(宿主 marshal 将 hard-fail 该请求): %v", id, err) + } + } + }) +} + +// FuzzResponseConversion 对全部已注册响应转换方向做模糊测试 +// (输入为上游响应体,由上游供应商控制——网关不能假设其良构)。 +func FuzzResponseConversion(f *testing.F) { + // 上游格式集合比入站多一个 Ollama + upstreamFormats := append(append([]types.RelayFormat{}, fuzzFormats...), types.RelayFormatOllama) + + seeds := []struct { + idx int + body string + }{ + {0, `{"id":"c","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`}, + {0, `{"choices":[]}`}, + {0, `{"choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"","type":"function","function":{"name":"","arguments":"{bad"}}]}}]}`}, + {1, `{"id":"m","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`}, + {1, `{"type":"message","content":[]}`}, + {1, `{"type":"message","content":[{"type":"tool_use","id":"","name":"","input":null}]}`}, + {2, `{"candidates":[{"content":{"role":"model","parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`}, + {2, `{"candidates":[]}`}, + {2, `{"candidates":[{"content":{"parts":[{"functionCall":{"name":"f"}}]},"finishReason":"SAFETY"}]}`}, + {2, `{"promptFeedback":{"blockReason":"SAFETY"}}`}, + {3, `{"id":"r","object":"response","status":"completed","output":[{"type":"message","id":"m","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`}, + {3, `{"status":"failed","error":{"code":"e","message":"m"},"output":[]}`}, + {4, `{"model":"m","message":{"role":"assistant","content":"hi"},"done":true,"prompt_eval_count":1,"eval_count":1}`}, + {4, `{"done":false}`}, + {0, `{}`}, {1, `{}`}, {2, `{}`}, {3, `{}`}, {4, `{}`}, + } + for _, s := range seeds { + f.Add(s.idx, s.body) + } + + f.Fuzz(func(t *testing.T, formatIdx int, body string) { + if formatIdx < 0 { + return + } + upstream := upstreamFormats[formatIdx%len(upstreamFormats)] + + parsed := fuzzParseResponse(upstream, []byte(body)) + if parsed == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for _, id := range relayconvert.ListResponseConverterIDs() { + spec, ok := relayconvert.LookupResponseConverter(id) + if !ok || spec.Convert == nil || spec.To != upstream { + continue + } + + converted, _, err := spec.Convert(ctx, capMeta(), parsed) + if err != nil { + continue + } + if converted == nil { + t.Fatalf("响应转换器 %s 返回了 (nil, nil)", id) + } + if _, err := json.Marshal(converted); err != nil { + t.Fatalf("响应转换器 %s 产出了不可序列化的结果: %v", id, err) + } + } + }) +} + +// fuzzParseResponse 按上游格式解析响应体(解析失败返回 nil)。 +func fuzzParseResponse(format types.RelayFormat, body []byte) any { + switch format { + case types.RelayFormatOpenAI: + var r dto.ChatCompletionResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + return &r + case types.RelayFormatClaude: + var r dto.ClaudeResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + return &r + case types.RelayFormatGemini: + var r dto.GeminiChatResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + return &r + case types.RelayFormatOpenAIResponses: + var r dto.OpenAIResponsesResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + return &r + case types.RelayFormatOllama: + var r dto.OllamaChatResponse + if json.Unmarshal(body, &r) != nil { + return nil + } + return &r + } + return nil +} + +// FuzzStreamConversion 对全部已注册流式方向做模糊测试。 +// 输入为上游 SSE / NDJSON 原始字节——由上游供应商控制,且流式解析器自己做分帧, +// 是「畸形输入」暴露面最大的一段(半个事件、超长行、嵌套引号、截断 JSON)。 +func FuzzStreamConversion(f *testing.F) { + streamFormats := []types.RelayFormat{ + types.RelayFormatOpenAI, + types.RelayFormatClaude, + types.RelayFormatGemini, + types.RelayFormatOpenAIResponses, + types.RelayFormatOllama, + } + + // 种子:金样本流式语料(文件名前缀即上游格式) + entries, _ := os.ReadDir(filepath.Join("golden", "stream_inputs")) + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".txt") { + continue + } + data, err := os.ReadFile(filepath.Join("golden", "stream_inputs", e.Name())) + if err != nil { + continue + } + formatName := strings.SplitN(strings.TrimSuffix(e.Name(), ".txt"), "__", 2)[0] + for i, format := range streamFormats { + if string(format) == formatName { + f.Add(i, string(data)) + break + } + } + } + + // 对抗种子:畸形分帧 + for _, s := range []struct { + idx int + body string + }{ + {0, "data: \n\n"}, + {0, "data: {\n\n"}, + {0, "data: [DONE]\n\n"}, + {0, "data: null\n\n"}, + {0, "garbage without prefix\n\n"}, + {0, "data: {\"choices\":[]}\n\ndata: [DONE]\n\n"}, + {1, "event: message_start\ndata: {}\n\n"}, + {1, "event: error\ndata: {\"type\":\"error\",\"error\":{}}\n\n"}, + {1, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":99,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\n"}, + {1, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"}, + {2, "data: {\"candidates\":null}\n\n"}, + {2, "data: {\"candidates\":[{\"content\":{\"parts\":null}}]}\n\n"}, + {3, "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"}, + {3, "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{}}\n\n"}, + {4, "{\"done\":true}\n"}, + {4, "not json\n{\"done\":true}\n"}, + {0, ""}, + } { + f.Add(s.idx, s.body) + } + + f.Fuzz(func(t *testing.T, formatIdx int, data string) { + if formatIdx < 0 { + return + } + upstream := streamFormats[formatIdx%len(streamFormats)] + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for _, spec := range relayconvert.ListStreamConverterSpecs() { + if spec.From != upstream { + continue + } + fn, ok := relayconvert.LookupStreamConverterByID(spec.ID) + if !ok { + continue + } + + // 不变量:不 panic;每个写出的 chunk 必须可序列化 + //(宿主经 WriteSSEDataJSON/WriteSSEEventJSON 序列化,失败即该帧被丢弃, + // 客户端收到不完整的流却看不到任何错误) + _ = fn(ctx, capMeta(), strings.NewReader(data), func(chunk any) error { + if chunk == nil { + return nil + } + payload := chunk + if ev, isEvent := chunk.(*relayconvert.StreamEvent); isEvent { + if ev == nil || ev.Data == nil { + return nil + } + payload = ev.Data + } + if _, err := json.Marshal(payload); err != nil { + t.Fatalf("流式转换器 %s 产出了不可序列化的 chunk(宿主会静默丢帧): %v", spec.ID, err) + } + return nil + }) + } + }) +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json new file mode 100644 index 0000000..061ffeb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/basic.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": "You are helpful.", + "role": "system" + }, + { + "content": "Hello, how are you?", + "role": "user" + } + ], + "model": "upstream-model", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json new file mode 100644 index 0000000..4ab320f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.request.json @@ -0,0 +1,95 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "You are a weather and finance assistant.", + "role": "system" + }, + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "image_url": { + "detail": "auto", + "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==" + }, + "type": "image_url" + } + ], + "role": "user" + }, + { + "content": "checking weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Boston\"}", + "name": "get_weather" + }, + "id": "toolu_1", + "type": "function" + } + ] + }, + { + "content": "68F, sunny", + "role": "tool", + "tool_call_id": "toolu_1" + }, + { + "content": "Thanks! What about the stock price of ACME?", + "role": "user" + } + ], + "model": "upstream-model", + "stop": [ + "END_TOKEN" + ], + "stream": true, + "temperature": 0.7, + "tool_choice": { + "function": { + "name": "get_weather" + }, + "type": "function" + }, + "tools": [ + { + "function": { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + "type": "function" + }, + { + "function": { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json new file mode 100644 index 0000000..dacca56 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/full.response.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "thinking": "CAP_MARKER_THINKING user asks weather", + "type": "thinking" + }, + { + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "type": "text" + }, + { + "id": "\u003cscrubbed\u003e", + "input": { + "location": "Boston" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "role": "assistant", + "stop_reason": "tool_use", + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 100, + "output_tokens": 50 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json new file mode 100644 index 0000000..a2f28ab --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/schema-variants.request.json @@ -0,0 +1,71 @@ +{ + "max_tokens": 512, + "messages": [ + { + "content": "Search the repo with the where filter.", + "role": "user" + } + ], + "model": "upstream-model", + "tools": [ + { + "function": { + "description": "search repo", + "name": "grep", + "parameters": { + "properties": { + "query": { + "properties": { + "paths": { + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + {} + ], + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json new file mode 100644 index 0000000..9a5f2b0 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/thinking.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": "Prove that sqrt(2) is irrational.", + "role": "user" + } + ], + "model": "upstream-model", + "reasoning_effort": "low" +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json new file mode 100644 index 0000000..5c41e04 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions/websearch.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "Search the latest AI news.", + "role": "user" + } + ], + "model": "upstream-model", + "web_search_options": {} +} diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json new file mode 100644 index 0000000..c03d97a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/basic.stream.json @@ -0,0 +1,85 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Hello", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 16, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 23 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json new file mode 100644 index 0000000..208cff3 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json @@ -0,0 +1,83 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "reasoning_content": "Let me think...", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "The answer is 42.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 10, + "total_tokens": 60 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json new file mode 100644 index 0000000..8406516 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json @@ -0,0 +1,150 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Let me check the weather.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":", + "name": "" + }, + "id": "\u003cscrubbed\u003e", + "type": "" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "\"Shanghai\"}", + "name": "" + }, + "id": "\u003cscrubbed\u003e", + "type": "" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "tool_calls", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json new file mode 100644 index 0000000..203dae7 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/basic.stream.json @@ -0,0 +1,63 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Hello" + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": ", world!" + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "modelVersion": "origin-model", + "usageMetadata": { + "cachedContentTokenCount": 4, + "candidatesTokenCount": 7, + "promptTokenCount": 16, + "totalTokenCount": 23 + } + }, + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json new file mode 100644 index 0000000..1f6fb95 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/thinking.stream.json @@ -0,0 +1,79 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Let me think...", + "thought": true + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "thought": true, + "thoughtSignature": "sig_abc123" + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "The answer is 42." + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "modelVersion": "origin-model", + "usageMetadata": { + "candidatesTokenCount": 50, + "promptTokenCount": 10, + "totalTokenCount": 60 + } + }, + "usage": { + "completion_tokens": 50, + "prompt_tokens": 10, + "prompt_tokens_details": {}, + "total_tokens": 60 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json new file mode 100644 index 0000000..43a692f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_chat_stream_resp/tools.stream.json @@ -0,0 +1,66 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Let me check the weather." + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": { + "city": "Shanghai" + }, + "id": "\u003cscrubbed\u003e", + "name": "get_weather" + } + } + ], + "role": "model" + } + } + ], + "modelVersion": "origin-model" + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "modelVersion": "origin-model", + "usageMetadata": { + "candidatesTokenCount": 9, + "promptTokenCount": 20, + "totalTokenCount": 29 + } + }, + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "prompt_tokens_details": {}, + "total_tokens": 29 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json new file mode 100644 index 0000000..04405eb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/basic.request.json @@ -0,0 +1,42 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Hello, how are you?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 256, + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are helpful." + } + ], + "role": "" + } +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json new file mode 100644 index 0000000..cbc277e --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.request.json @@ -0,0 +1,131 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Describe this image and check the weather in Boston." + }, + { + "inlineData": { + "data": "/9j/4AAQSkZJRg==", + "mimeType": "image/jpeg" + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "checking weather" + }, + { + "functionCall": { + "args": { + "location": "Boston" + }, + "id": "toolu_1", + "name": "get_weather" + } + } + ], + "role": "model" + }, + { + "parts": [ + { + "functionResponse": { + "id": "toolu_1", + "name": "get_weather", + "response": { + "result": "68F, sunny" + } + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "Thanks! What about the stock price of ACME?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024, + "stopSequences": [ + "END_TOKEN" + ], + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are a weather and finance assistant." + } + ], + "role": "" + }, + "toolConfig": { + "functionCallingConfig": { + "allowedFunctionNames": [ + "get_weather" + ], + "mode": "ANY" + } + }, + "tools": [ + { + "functionDeclarations": [ + { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json new file mode 100644 index 0000000..dacca56 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/full.response.json @@ -0,0 +1,31 @@ +{ + "content": [ + { + "thinking": "CAP_MARKER_THINKING user asks weather", + "type": "thinking" + }, + { + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "type": "text" + }, + { + "id": "\u003cscrubbed\u003e", + "input": { + "location": "Boston" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "role": "assistant", + "stop_reason": "tool_use", + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 100, + "output_tokens": 50 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json new file mode 100644 index 0000000..bbd058a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/schema-variants.request.json @@ -0,0 +1,102 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Search the repo with the where filter." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 512 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "tools": [ + { + "functionDeclarations": [ + { + "description": "search repo", + "name": "grep", + "parameters": { + "properties": { + "query": { + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json new file mode 100644 index 0000000..35fd191 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/thinking.request.json @@ -0,0 +1,37 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Prove that sqrt(2) is irrational." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 4096, + "thinkingConfig": { + "includeThoughts": true, + "thinkingBudget": 2048 + } + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json new file mode 100644 index 0000000..39692f3 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_gemini_generate_content/websearch.request.json @@ -0,0 +1,38 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Search the latest AI news." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "tools": [ + { + "googleSearch": {} + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json new file mode 100644 index 0000000..c73f728 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/basic.stream.json @@ -0,0 +1,204 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "delta": "Hello", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": ", world!", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "Hello, world!", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 16, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 4 + }, + "output_tokens": 7, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 23 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json new file mode 100644 index 0000000..22cfe25 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json @@ -0,0 +1,202 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "delta": "Let me think...", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "summary_index": 0, + "type": "response.reasoning_summary_text.delta" + }, + "event": "response.reasoning_summary_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": "The answer is 42.", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "The answer is 42.", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 60 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 10, + "prompt_tokens_details": {}, + "total_tokens": 60 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json new file mode 100644 index 0000000..0ba3028 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/tools.stream.json @@ -0,0 +1,256 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "delta": "Let me check the weather.", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "Let me check the weather.", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "item": { + "call_id": "toolu_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "in_progress", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "delta": "{\"city\":", + "item_id": "toolu_1", + "output_index": 1, + "type": "response.function_call_arguments.delta" + }, + "event": "response.function_call_arguments.delta" + }, + { + "data": { + "delta": "\"Shanghai\"}", + "item_id": "toolu_1", + "output_index": 1, + "type": "response.function_call_arguments.delta" + }, + "event": "response.function_call_arguments.delta" + }, + { + "data": { + "arguments": "{\"city\":\"Shanghai\"}", + "item_id": "toolu_1", + "output_index": 1, + "type": "response.function_call_arguments.done" + }, + "event": "response.function_call_arguments.done" + }, + { + "data": { + "item": { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "toolu_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "toolu_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 20, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 9, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 29 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "prompt_tokens_details": {}, + "total_tokens": 29 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json new file mode 100644 index 0000000..8a2741e --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/basic.stream.json @@ -0,0 +1,93 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "text": "", + "type": "text" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "text": "Hello", + "type": "text_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "delta": { + "text": ", world!", + "type": "text_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "end_turn" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 4, + "input_tokens": 8, + "output_tokens": 7 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 7, + "completion_tokens_details": {}, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json new file mode 100644 index 0000000..930c075 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/thinking.stream.json @@ -0,0 +1,122 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "thinking": "", + "type": "thinking" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "thinking": "Let me think...", + "type": "thinking_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "delta": { + "signature": "sig_abc123", + "type": "signature_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "content_block": { + "text": "", + "type": "text" + }, + "index": 1, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "text": "The answer is 42.", + "type": "text_delta" + }, + "index": 1, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 1, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "end_turn" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 10, + "output_tokens": 80 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 80, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "prompt_tokens_details": {}, + "total_tokens": 60 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json new file mode 100644 index 0000000..7898b29 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_claude_messages_stream_resp/tools.stream.json @@ -0,0 +1,111 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "text": "", + "type": "text" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "text": "Let me check the weather.", + "type": "text_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "content_block": { + "id": "\u003cscrubbed\u003e", + "input": {}, + "name": "get_weather", + "type": "tool_use" + }, + "index": 1, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "partial_json": "{\"city\":\"Shanghai\"}", + "type": "input_json_delta" + }, + "index": 1, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 1, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "tool_use" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 20, + "output_tokens": 9 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": {}, + "prompt_tokens": 20, + "prompt_tokens_details": {}, + "total_tokens": 29 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json new file mode 100644 index 0000000..fd4c9a3 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/basic.stream.json @@ -0,0 +1,85 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Hello", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json new file mode 100644 index 0000000..929a609 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/thinking.stream.json @@ -0,0 +1,86 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "reasoning_content": "Let me think...", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "The answer is 42.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 80, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "total_tokens": 60 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json new file mode 100644 index 0000000..104340b --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,92 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Let me check the weather.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Shanghai\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json new file mode 100644 index 0000000..efc6946 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/basic.stream.json @@ -0,0 +1,208 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "delta": "Hello", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": ", world!", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "Hello, world!", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 12, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 4 + }, + "output_tokens": 7, + "output_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "total_tokens": 19 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 7, + "completion_tokens_details": {}, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json new file mode 100644 index 0000000..8ea2260 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/thinking.stream.json @@ -0,0 +1,208 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "delta": "Let me think...", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "summary_index": 0, + "type": "response.reasoning_summary_text.delta" + }, + "event": "response.reasoning_summary_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": "The answer is 42.", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "The answer is 42.", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 80, + "output_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 30, + "rejected_prediction_tokens": 0 + }, + "total_tokens": 60 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 80, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "prompt_tokens_details": {}, + "total_tokens": 60 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json new file mode 100644 index 0000000..ad0488e --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_responses_stream_resp/tools.stream.json @@ -0,0 +1,251 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "delta": "Let me check the weather.", + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "text": "Let me check the weather.", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "\u003cscrubbed\u003e", + "output_index": 0, + "part": { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "item": { + "call_id": "\u003cscrubbed\u003e", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "in_progress", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "delta": "{\"city\":\"Shanghai\"}", + "item_id": "\u003cscrubbed\u003e", + "output_index": 1, + "type": "response.function_call_arguments.delta" + }, + "event": "response.function_call_arguments.delta" + }, + { + "data": { + "arguments": "{\"city\":\"Shanghai\"}", + "item_id": "\u003cscrubbed\u003e", + "output_index": 1, + "type": "response.function_call_arguments.done" + }, + "event": "response.function_call_arguments.done" + }, + { + "data": { + "item": { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "\u003cscrubbed\u003e", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Let me check the weather.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "\u003cscrubbed\u003e", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 20, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 9, + "output_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "total_tokens": 29 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 9, + "completion_tokens_details": {}, + "prompt_tokens": 20, + "prompt_tokens_details": {}, + "total_tokens": 29 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json new file mode 100644 index 0000000..cdab73b --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/basic.request.json @@ -0,0 +1,17 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": [ + { + "text": "Hello, how are you?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "system": "You are helpful.", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json new file mode 100644 index 0000000..0020d7a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.request.json @@ -0,0 +1,97 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "source": { + "data": "/9j/4AAQSkZJRg==", + "media_type": "image/jpeg", + "type": "base64" + }, + "type": "image" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "checking weather", + "type": "text" + }, + { + "id": "call_0", + "input": { + "location": "Boston" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": "{\"result\":\"68F, sunny\"}", + "tool_use_id": "call_0", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "Thanks! What about the stock price of ACME?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "stop_sequences": [ + "END_TOKEN" + ], + "system": "You are a weather and finance assistant.", + "temperature": 0.7, + "tool_choice": { + "name": "get_weather", + "type": "tool" + }, + "tools": [ + { + "description": "get weather", + "input_schema": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + }, + "name": "get_weather" + }, + { + "description": "stock quote", + "input_schema": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + }, + "name": "lookup_stock" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json new file mode 100644 index 0000000..eb88e2a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/full.response.json @@ -0,0 +1,35 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "CAP_MARKER_THINKING user asks weather", + "thought": true, + "thoughtSignature": "sig_abc" + }, + { + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F" + }, + { + "functionCall": { + "args": { + "location": "Boston" + }, + "id": "\u003cscrubbed\u003e", + "name": "get_weather" + } + } + ], + "role": "model" + }, + "finishReason": "STOP" + } + ], + "modelVersion": "origin-model", + "usageMetadata": { + "candidatesTokenCount": 50, + "promptTokenCount": 100, + "totalTokenCount": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json new file mode 100644 index 0000000..09ded16 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/thinking.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": [ + { + "text": "Prove that sqrt(2) is irrational.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model" +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json new file mode 100644 index 0000000..474160f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_claude_messages/websearch.request.json @@ -0,0 +1,21 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Search the latest AI news.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "tools": [ + { + "name": "web_search", + "type": "web_search_20250305" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json new file mode 100644 index 0000000..061ffeb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/basic.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": "You are helpful.", + "role": "system" + }, + { + "content": "Hello, how are you?", + "role": "user" + } + ], + "model": "upstream-model", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json new file mode 100644 index 0000000..741fa8c --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.request.json @@ -0,0 +1,94 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "You are a weather and finance assistant.", + "role": "system" + }, + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "image_url": { + "detail": "auto", + "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==" + }, + "type": "image_url" + } + ], + "role": "user" + }, + { + "content": "checking weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Boston\"}", + "name": "get_weather" + }, + "id": "call_0", + "type": "function" + } + ] + }, + { + "content": "{\"result\":\"68F, sunny\"}", + "role": "tool", + "tool_call_id": "call_0" + }, + { + "content": "Thanks! What about the stock price of ACME?", + "role": "user" + } + ], + "model": "upstream-model", + "stop": [ + "END_TOKEN" + ], + "temperature": 0.7, + "tool_choice": { + "function": { + "name": "get_weather" + }, + "type": "function" + }, + "tools": [ + { + "function": { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + "type": "function" + }, + { + "function": { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json new file mode 100644 index 0000000..de3584a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/full.response.json @@ -0,0 +1,33 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "CAP_MARKER_THINKING user asks weather", + "thought": true + }, + { + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F" + }, + { + "functionCall": { + "args": { + "location": "Boston" + }, + "id": "\u003cscrubbed\u003e", + "name": "get_weather" + } + } + ], + "role": "model" + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "candidatesTokenCount": 50, + "promptTokenCount": 100, + "totalTokenCount": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json new file mode 100644 index 0000000..9a5f2b0 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/thinking.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": "Prove that sqrt(2) is irrational.", + "role": "user" + } + ], + "model": "upstream-model", + "reasoning_effort": "low" +} diff --git a/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json new file mode 100644 index 0000000..5c41e04 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_generate_content_to_openai_chat_completions/websearch.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "Search the latest AI news.", + "role": "user" + } + ], + "model": "upstream-model", + "web_search_options": {} +} diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json new file mode 100644 index 0000000..f4d7389 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/basic.stream.json @@ -0,0 +1,92 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "upstream-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "text": "", + "type": "text" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "text": "Hello", + "type": "text_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "delta": { + "text": ", world!", + "type": "text_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "end_turn" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 4, + "input_tokens": 8, + "output_tokens": 7 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json new file mode 100644 index 0000000..08a0c2f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/thinking.stream.json @@ -0,0 +1,110 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "upstream-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "thinking": "", + "type": "thinking" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "thinking": "Let me think...", + "type": "thinking_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "content_block": { + "text": "", + "type": "text" + }, + "index": 1, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "text": "The answer is 42.", + "type": "text_delta" + }, + "index": 1, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 1, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "end_turn" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 10, + "output_tokens": 50 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 50, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "total_tokens": 60 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json new file mode 100644 index 0000000..e89bd57 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_claude_messages_stream_resp/tools.stream.json @@ -0,0 +1,91 @@ +[ + { + "data": { + "message": { + "content": [], + "id": "\u003cscrubbed\u003e", + "model": "upstream-model", + "role": "assistant", + "stop_reason": null, + "stop_sequence": null, + "type": "message", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 0, + "output_tokens": 0 + } + }, + "type": "message_start" + }, + "event": "message_start" + }, + { + "data": { + "content_block": { + "id": "\u003cscrubbed\u003e", + "input": {}, + "name": "get_weather", + "type": "tool_use" + }, + "index": 0, + "type": "content_block_start" + }, + "event": "content_block_start" + }, + { + "data": { + "delta": { + "partial_json": "{\"city\":", + "type": "input_json_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "delta": { + "partial_json": "\"Shanghai\"}", + "type": "input_json_delta" + }, + "index": 0, + "type": "content_block_delta" + }, + "event": "content_block_delta" + }, + { + "data": { + "index": 0, + "type": "content_block_stop" + }, + "event": "content_block_stop" + }, + { + "data": { + "delta": { + "stop_reason": "tool_use" + }, + "type": "message_delta", + "usage": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input_tokens": 20, + "output_tokens": 9 + } + }, + "event": "message_delta", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + }, + { + "data": { + "type": "message_stop" + }, + "event": "message_stop" + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json new file mode 100644 index 0000000..39bcfc8 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/basic.stream.json @@ -0,0 +1,71 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Hello" + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": ", world!" + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "cachedContentTokenCount": 4, + "candidatesTokenCount": 7, + "promptTokenCount": 12, + "totalTokenCount": 19 + } + }, + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json new file mode 100644 index 0000000..5ee15a4 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/thinking.stream.json @@ -0,0 +1,72 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Let me think...", + "thought": true + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "The answer is 42." + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "candidatesTokenCount": 20, + "promptTokenCount": 10, + "thoughtsTokenCount": 30, + "totalTokenCount": 60 + } + }, + "usage": { + "completion_tokens": 50, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "total_tokens": 60 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json new file mode 100644 index 0000000..605ff8e --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_gemini_chat_stream_resp/tools.stream.json @@ -0,0 +1,92 @@ +[ + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "id": "\u003cscrubbed\u003e", + "name": "get_weather" + } + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": {}, + "name": "" + } + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "args": {}, + "name": "" + } + } + ], + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + } + } + ] + } + }, + { + "data": { + "candidates": [ + { + "content": { + "role": "model" + }, + "finishReason": "STOP" + } + ], + "usageMetadata": { + "candidatesTokenCount": 9, + "promptTokenCount": 20, + "totalTokenCount": 29 + } + }, + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json new file mode 100644 index 0000000..09491eb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/basic.stream.json @@ -0,0 +1,204 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "delta": "Hello", + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": ", world!", + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "text": "Hello, world!", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "Hello, world!", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 12, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 4 + }, + "output_tokens": 7, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 19 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json new file mode 100644 index 0000000..a285abd --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json @@ -0,0 +1,257 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "item": { + "id": "\u003cscrubbed\u003e", + "status": "in_progress", + "summary": [], + "type": "reasoning" + }, + "output_index": 1, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "delta": "Let me think...", + "item_id": "rs_resp_chatcmpl-up", + "output_index": 1, + "summary_index": 0, + "type": "response.reasoning_summary_text.delta" + }, + "event": "response.reasoning_summary_text.delta" + }, + { + "data": { + "content_index": 0, + "delta": "The answer is 42.", + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "item_id": "rs_resp_chatcmpl-up", + "output_index": 1, + "summary_index": 0, + "text": "Let me think...", + "type": "response.reasoning_summary_text.done" + }, + "event": "response.reasoning_summary_text.done" + }, + { + "data": { + "item": { + "id": "\u003cscrubbed\u003e", + "status": "completed", + "summary": [ + { + "text": "Let me think...", + "type": "summary_text" + } + ], + "type": "reasoning" + }, + "output_index": 1, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "text": "The answer is 42.", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "content": [ + { + "annotations": [], + "text": "The answer is 42.", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "id": "\u003cscrubbed\u003e", + "status": "completed", + "summary": [ + { + "text": "Let me think...", + "type": "summary_text" + } + ], + "type": "reasoning" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 10, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 30, + "rejected_prediction_tokens": 0 + }, + "total_tokens": 60 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 50, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "total_tokens": 60 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json new file mode 100644 index 0000000..63d665d --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/tools.stream.json @@ -0,0 +1,230 @@ +[ + { + "data": { + "response": { + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "in_progress", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "user": null + }, + "type": "response.created" + }, + "event": "response.created" + }, + { + "data": { + "item": { + "content": [], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "in_progress", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.added" + }, + "event": "response.content_part.added" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "text": "", + "type": "response.output_text.done" + }, + "event": "response.output_text.done" + }, + { + "data": { + "content_index": 0, + "item_id": "msg_chatcmpl-up", + "output_index": 0, + "part": { + "annotations": [], + "text": "", + "type": "output_text" + }, + "type": "response.content_part.done" + }, + "event": "response.content_part.done" + }, + { + "data": { + "item": { + "content": [ + { + "annotations": [], + "text": "", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + "output_index": 0, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "item": { + "call_id": "call_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "in_progress", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "delta": "{\"city\":", + "item_id": "call_1", + "output_index": 1, + "type": "response.function_call_arguments.delta" + }, + "event": "response.function_call_arguments.delta" + }, + { + "data": { + "delta": "\"Shanghai\"}", + "item_id": "call_1", + "output_index": 1, + "type": "response.function_call_arguments.delta" + }, + "event": "response.function_call_arguments.delta" + }, + { + "data": { + "arguments": "{\"city\":\"Shanghai\"}", + "item_id": "call_1", + "output_index": 1, + "type": "response.function_call_arguments.done" + }, + "event": "response.function_call_arguments.done" + }, + { + "data": { + "item": { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "call_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + }, + "output_index": 1, + "type": "response.output_item.done" + }, + "event": "response.output_item.done" + }, + { + "data": { + "response": { + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "arguments": "{\"city\":\"Shanghai\"}", + "call_id": "call_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 20, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 9, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 29 + }, + "user": null + }, + "type": "response.completed" + }, + "event": "response.completed", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json new file mode 100644 index 0000000..e7bd056 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.stream.json @@ -0,0 +1,95 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Hello", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 19 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json new file mode 100644 index 0000000..8d3a00f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json @@ -0,0 +1,96 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "reasoning_content": "Let me think...", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "The answer is 42.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 50, + "completion_tokens_details": { + "reasoning_tokens": 30 + }, + "prompt_tokens": 10, + "total_tokens": 60 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json new file mode 100644 index 0000000..67b6b82 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,141 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":", + "name": "" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "\"Shanghai\"}", + "name": "" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "tool_calls", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json new file mode 100644 index 0000000..5dbd99d --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json @@ -0,0 +1,82 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "Hello", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 7, + "prompt_tokens": 12, + "total_tokens": 19 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json new file mode 100644 index 0000000..41c80d1 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json @@ -0,0 +1,83 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "reasoning_content": "Let me think...", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "The answer is 42.", + "role": "" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "stop", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 10, + "total_tokens": 60 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json new file mode 100644 index 0000000..de1380a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,73 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "assistant" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": "", + "role": "", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Shanghai\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "role": "" + }, + "finish_reason": "tool_calls", + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": { + "completion_tokens": 9, + "prompt_tokens": 20, + "total_tokens": 29 + } + } + } +] diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json new file mode 100644 index 0000000..cdab73b --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/basic.request.json @@ -0,0 +1,17 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": [ + { + "text": "Hello, how are you?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "system": "You are helpful.", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json new file mode 100644 index 0000000..6c180ed --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.request.json @@ -0,0 +1,98 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "source": { + "data": "/9j/4AAQSkZJRg==", + "media_type": "image/jpeg", + "type": "base64" + }, + "type": "image" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "", + "type": "text" + }, + { + "id": "call_1", + "input": { + "location": "Boston" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": "68F, sunny", + "tool_use_id": "call_1", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "Thanks! What about the stock price of ACME?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "stop_sequences": [ + "END_TOKEN" + ], + "stream": true, + "system": "You are a weather and finance assistant.", + "temperature": 0.7, + "tool_choice": { + "name": "get_weather", + "type": "tool" + }, + "tools": [ + { + "description": "get weather", + "input_schema": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + }, + "name": "get_weather" + }, + { + "description": "stock quote", + "input_schema": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + }, + "name": "lookup_stock" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json new file mode 100644 index 0000000..babdae7 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/full.response.json @@ -0,0 +1,32 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "reasoning_content": "CAP_MARKER_THINKING user asks weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Boston\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + } + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "upstream-model", + "object": "chat.completion", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 100, + "total_tokens": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json new file mode 100644 index 0000000..1793e41 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/schema-variants.request.json @@ -0,0 +1,73 @@ +{ + "max_tokens": 512, + "messages": [ + { + "content": [ + { + "text": "Search the repo with the where filter.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "tools": [ + { + "description": "search repo", + "input_schema": { + "properties": { + "query": { + "properties": { + "paths": { + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + {} + ], + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "grep" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json new file mode 100644 index 0000000..09ded16 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/thinking.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": [ + { + "text": "Prove that sqrt(2) is irrational.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model" +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json new file mode 100644 index 0000000..474160f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_anthropic_messages/websearch.request.json @@ -0,0 +1,21 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Search the latest AI news.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "tools": [ + { + "name": "web_search", + "type": "web_search_20250305" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json new file mode 100644 index 0000000..04405eb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/basic.request.json @@ -0,0 +1,42 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Hello, how are you?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 256, + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are helpful." + } + ], + "role": "" + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json new file mode 100644 index 0000000..ac68e34 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.request.json @@ -0,0 +1,126 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Describe this image and check the weather in Boston." + }, + { + "inlineData": { + "data": "/9j/4AAQSkZJRg==", + "mimeType": "image/jpeg" + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "functionCall": { + "args": { + "location": "Boston" + }, + "name": "get_weather" + } + } + ], + "role": "model" + }, + { + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": { + "result": "68F, sunny" + } + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "Thanks! What about the stock price of ACME?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024, + "stopSequences": [ + "END_TOKEN" + ], + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are a weather and finance assistant." + } + ], + "role": "" + }, + "toolConfig": { + "functionCallingConfig": { + "allowedFunctionNames": [ + "get_weather" + ], + "mode": "ANY" + } + }, + "tools": [ + { + "functionDeclarations": [ + { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json new file mode 100644 index 0000000..babdae7 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/full.response.json @@ -0,0 +1,32 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "reasoning_content": "CAP_MARKER_THINKING user asks weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Boston\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + } + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "upstream-model", + "object": "chat.completion", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 100, + "total_tokens": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json new file mode 100644 index 0000000..bbd058a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/schema-variants.request.json @@ -0,0 +1,102 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Search the repo with the where filter." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 512 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "tools": [ + { + "functionDeclarations": [ + { + "description": "search repo", + "name": "grep", + "parameters": { + "properties": { + "query": { + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json new file mode 100644 index 0000000..bc184b2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/thinking.request.json @@ -0,0 +1,37 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Prove that sqrt(2) is irrational." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 4096, + "thinkingConfig": { + "includeThoughts": true, + "thinkingBudget": 32768 + } + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json new file mode 100644 index 0000000..39692f3 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_gemini_generate_content/websearch.request.json @@ -0,0 +1,38 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Search the latest AI news." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "tools": [ + { + "googleSearch": {} + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json new file mode 100644 index 0000000..66f7ed4 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/basic.request.json @@ -0,0 +1,18 @@ +{ + "messages": [ + { + "content": "You are helpful.", + "role": "system" + }, + { + "content": "Hello, how are you?", + "role": "user" + } + ], + "model": "upstream-model", + "options": { + "num_predict": 256, + "temperature": 0.7 + }, + "stream": false +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json new file mode 100644 index 0000000..1665637 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.request.json @@ -0,0 +1,84 @@ +{ + "messages": [ + { + "content": "You are a weather and finance assistant.", + "role": "system" + }, + { + "content": "Describe this image and check the weather in Boston.", + "images": [ + "/9j/4AAQSkZJRg==" + ], + "role": "user" + }, + { + "content": "", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": { + "location": "Boston" + }, + "name": "get_weather" + }, + "id": "call_1" + } + ] + }, + { + "content": "68F, sunny", + "role": "tool", + "tool_call_id": "call_1", + "tool_name": "get_weather" + }, + { + "content": "Thanks! What about the stock price of ACME?", + "role": "user" + } + ], + "model": "upstream-model", + "options": { + "num_predict": 1024, + "stop": [ + "END_TOKEN" + ], + "temperature": 0.7 + }, + "stream": false, + "tools": [ + { + "function": { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + "type": "function" + }, + { + "function": { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json new file mode 100644 index 0000000..b9bb77d --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/full.response.json @@ -0,0 +1,32 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "reasoning_content": "CAP_MARKER_THINKING user asks weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Boston\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + } + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 100, + "total_tokens": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json new file mode 100644 index 0000000..58307cf --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/schema-variants.request.json @@ -0,0 +1,74 @@ +{ + "messages": [ + { + "content": "Search the repo with the where filter.", + "role": "user" + } + ], + "model": "upstream-model", + "options": { + "num_predict": 512 + }, + "stream": false, + "tools": [ + { + "function": { + "description": "search repo", + "name": "grep", + "parameters": { + "properties": { + "query": { + "properties": { + "paths": { + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + {} + ], + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json new file mode 100644 index 0000000..fa166c2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/thinking.request.json @@ -0,0 +1,14 @@ +{ + "messages": [ + { + "content": "Prove that sqrt(2) is irrational.", + "role": "user" + } + ], + "model": "upstream-model", + "options": { + "num_predict": 4096 + }, + "stream": false, + "think": "high" +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json new file mode 100644 index 0000000..9e07bf0 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_ollama_chat/websearch.request.json @@ -0,0 +1,13 @@ +{ + "messages": [ + { + "content": "Search the latest AI news.", + "role": "user" + } + ], + "model": "upstream-model", + "options": { + "num_predict": 1024 + }, + "stream": false +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json new file mode 100644 index 0000000..8d47fd6 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/basic.request.json @@ -0,0 +1,19 @@ +{ + "input": [ + { + "content": [ + { + "text": "Hello, how are you?", + "type": "input_text" + } + ], + "role": "user", + "type": "message" + } + ], + "instructions": "You are helpful.", + "max_output_tokens": 256, + "model": "upstream-model", + "store": false, + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json new file mode 100644 index 0000000..b141f78 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.request.json @@ -0,0 +1,80 @@ +{ + "input": [ + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "input_text" + }, + { + "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==", + "type": "input_image" + } + ], + "role": "user", + "type": "message" + }, + { + "arguments": "{\"location\": \"Boston\"}", + "call_id": "call_1", + "name": "get_weather", + "type": "function_call" + }, + { + "call_id": "call_1", + "output": "68F, sunny", + "type": "function_call_output" + }, + { + "content": [ + { + "text": "Thanks! What about the stock price of ACME?", + "type": "input_text" + } + ], + "role": "user", + "type": "message" + } + ], + "instructions": "You are a weather and finance assistant.", + "max_output_tokens": 1024, + "model": "upstream-model", + "store": false, + "stream": true, + "temperature": 0.7, + "tool_choice": { + "name": "get_weather", + "type": "function" + }, + "tools": [ + { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + }, + "type": "function" + }, + { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json new file mode 100644 index 0000000..872537d --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/full.response.json @@ -0,0 +1,32 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "reasoning_content": "CAP_MARKER_THINKING user asks weather", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\": \"Boston\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "type": "function" + } + ] + } + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion", + "usage": { + "completion_tokens": 50, + "prompt_tokens": 100, + "total_tokens": 150 + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json new file mode 100644 index 0000000..5f7415f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/schema-variants.request.json @@ -0,0 +1,76 @@ +{ + "input": [ + { + "content": [ + { + "text": "Search the repo with the where filter.", + "type": "input_text" + } + ], + "role": "user", + "type": "message" + } + ], + "max_output_tokens": 512, + "model": "upstream-model", + "store": false, + "tools": [ + { + "description": "search repo", + "name": "grep", + "parameters": { + "properties": { + "query": { + "properties": { + "paths": { + "type": "array" + }, + "pattern": { + "type": "string" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "where": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "enum": [ + "AND", + "OR" + ], + "type": "string" + }, + {} + ], + "type": "array" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json new file mode 100644 index 0000000..84f8723 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/thinking.request.json @@ -0,0 +1,21 @@ +{ + "input": [ + { + "content": [ + { + "text": "Prove that sqrt(2) is irrational.", + "type": "input_text" + } + ], + "role": "user", + "type": "message" + } + ], + "max_output_tokens": 4096, + "model": "upstream-model", + "reasoning": { + "effort": "high", + "summary": "detailed" + }, + "store": false +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json new file mode 100644 index 0000000..e9f9289 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_chat_completions_to_openai_responses/websearch.request.json @@ -0,0 +1,22 @@ +{ + "input": [ + { + "content": [ + { + "text": "Search the latest AI news.", + "type": "input_text" + } + ], + "role": "user", + "type": "message" + } + ], + "max_output_tokens": 1024, + "model": "upstream-model", + "store": false, + "tools": [ + { + "type": "web_search" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json new file mode 100644 index 0000000..cdab73b --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/basic.request.json @@ -0,0 +1,17 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": [ + { + "text": "Hello, how are you?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "system": "You are helpful.", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json new file mode 100644 index 0000000..ae058dd --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.request.json @@ -0,0 +1,95 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "source": { + "data": "/9j/4AAQSkZJRg==", + "media_type": "image/jpeg", + "type": "base64" + }, + "type": "image" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "", + "type": "text" + }, + { + "id": "call_1", + "input": { + "location": "Boston" + }, + "name": "get_weather", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": "68F, sunny", + "tool_use_id": "call_1", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "Thanks! What about the stock price of ACME?", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "stream": true, + "system": "You are a weather and finance assistant.", + "temperature": 0.7, + "tool_choice": { + "name": "get_weather", + "type": "tool" + }, + "tools": [ + { + "description": "get weather", + "input_schema": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + }, + "name": "get_weather" + }, + { + "description": "stock quote", + "input_schema": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + }, + "name": "lookup_stock" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json new file mode 100644 index 0000000..69df3cf --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/full.response.json @@ -0,0 +1,77 @@ +{ + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "id": "\u003cscrubbed\u003e", + "summary": [ + { + "text": "CAP_MARKER_THINKING user asks weather", + "type": "summary_text" + } + ], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "arguments": "{\"location\":\"Boston\"}", + "call_id": "toolu_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 150 + }, + "user": null +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json new file mode 100644 index 0000000..f6c9d44 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/reasoning-roundtrip.request.json @@ -0,0 +1,78 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": [ + { + "text": "list files", + "type": "text" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "", + "type": "text" + }, + { + "id": "call_1", + "input": { + "cmd": "ls" + }, + "name": "shell", + "type": "tool_use" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "content": "Chunk ID: ab12cd\nOutput:\na.go b.go", + "tool_use_id": "call_1", + "type": "tool_result" + } + ], + "role": "user" + }, + { + "content": [ + { + "text": "目录里有两个 Go 文件。", + "type": "text" + } + ], + "role": "assistant" + }, + { + "content": [ + { + "text": "open a.go", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "stream": true, + "system": "You are a coding agent.", + "tools": [ + { + "input_schema": { + "properties": { + "cmd": { + "type": "string" + } + }, + "required": [ + "cmd" + ], + "type": "object" + }, + "name": "shell" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json new file mode 100644 index 0000000..09ded16 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/thinking.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": [ + { + "text": "Prove that sqrt(2) is irrational.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model" +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json new file mode 100644 index 0000000..474160f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_claude_messages/websearch.request.json @@ -0,0 +1,21 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": [ + { + "text": "Search the latest AI news.", + "type": "text" + } + ], + "role": "user" + } + ], + "model": "upstream-model", + "tools": [ + { + "name": "web_search", + "type": "web_search_20250305" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json new file mode 100644 index 0000000..04405eb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/basic.request.json @@ -0,0 +1,42 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Hello, how are you?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 256, + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are helpful." + } + ], + "role": "" + } +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json new file mode 100644 index 0000000..57af41a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.request.json @@ -0,0 +1,123 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Describe this image and check the weather in Boston." + }, + { + "inlineData": { + "data": "/9j/4AAQSkZJRg==", + "mimeType": "image/jpeg" + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "functionCall": { + "args": { + "location": "Boston" + }, + "name": "get_weather" + } + } + ], + "role": "model" + }, + { + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": { + "result": "68F, sunny" + } + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "Thanks! What about the stock price of ACME?" + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024, + "temperature": 0.7 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are a weather and finance assistant." + } + ], + "role": "" + }, + "toolConfig": { + "functionCallingConfig": { + "allowedFunctionNames": [ + "get_weather" + ], + "mode": "ANY" + } + }, + "tools": [ + { + "functionDeclarations": [ + { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json new file mode 100644 index 0000000..0c36a93 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/full.response.json @@ -0,0 +1,80 @@ +{ + "completed_at": 0, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "id": "\u003cscrubbed\u003e", + "summary": [ + { + "text": "CAP_MARKER_THINKING user asks weather", + "type": "summary_text" + } + ], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "arguments": "{\"location\":\"Boston\"}", + "call_id": "\u003cscrubbed\u003e", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "audio_tokens": 0, + "cache_write_tokens": 0, + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "total_tokens": 150 + }, + "user": null +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json new file mode 100644 index 0000000..d7c5304 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/reasoning-roundtrip.request.json @@ -0,0 +1,109 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "list files" + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "先用 shell 工具查看目录结构。", + "thought": true + }, + { + "functionCall": { + "args": { + "cmd": "ls" + }, + "name": "shell" + } + } + ], + "role": "model" + }, + { + "parts": [ + { + "functionResponse": { + "name": "shell", + "response": { + "result": "Chunk ID: ab12cd\nOutput:\na.go b.go" + } + } + } + ], + "role": "user" + }, + { + "parts": [ + { + "text": "目录里有两个 Go 文件。" + }, + { + "text": "根据目录内容回复用户。", + "thought": true + } + ], + "role": "model" + }, + { + "parts": [ + { + "text": "open a.go" + } + ], + "role": "user" + } + ], + "generationConfig": {}, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "systemInstruction": { + "parts": [ + { + "text": "You are a coding agent." + } + ], + "role": "" + }, + "tools": [ + { + "functionDeclarations": [ + { + "name": "shell", + "parameters": { + "properties": { + "cmd": { + "type": "string" + } + }, + "required": [ + "cmd" + ], + "type": "object" + } + } + ] + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json new file mode 100644 index 0000000..bc184b2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/thinking.request.json @@ -0,0 +1,37 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Prove that sqrt(2) is irrational." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 4096, + "thinkingConfig": { + "includeThoughts": true, + "thinkingBudget": 32768 + } + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json new file mode 100644 index 0000000..39692f3 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_gemini_generate_content/websearch.request.json @@ -0,0 +1,38 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "Search the latest AI news." + } + ], + "role": "user" + } + ], + "generationConfig": { + "maxOutputTokens": 1024 + }, + "safetySettings": [ + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_ONLY_HIGH" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_ONLY_HIGH" + } + ], + "tools": [ + { + "googleSearch": {} + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json new file mode 100644 index 0000000..061ffeb --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/basic.request.json @@ -0,0 +1,15 @@ +{ + "max_tokens": 256, + "messages": [ + { + "content": "You are helpful.", + "role": "system" + }, + { + "content": "Hello, how are you?", + "role": "user" + } + ], + "model": "upstream-model", + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json new file mode 100644 index 0000000..149ed53 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.request.json @@ -0,0 +1,94 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "You are a weather and finance assistant.", + "role": "system" + }, + { + "content": [ + { + "text": "Describe this image and check the weather in Boston.", + "type": "text" + }, + { + "image_url": { + "url": "data:image/jpeg;base64,/9j/4AAQSkZJRg==" + }, + "type": "image_url" + } + ], + "role": "user" + }, + { + "content": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\": \"Boston\"}", + "name": "get_weather" + }, + "id": "call_1", + "type": "function" + } + ] + }, + { + "content": "68F, sunny", + "role": "tool", + "tool_call_id": "call_1" + }, + { + "content": "Thanks! What about the stock price of ACME?", + "role": "user" + } + ], + "model": "upstream-model", + "stream": true, + "stream_options": { + "include_usage": true + }, + "temperature": 0.7, + "tool_choice": { + "function": { + "name": "get_weather" + }, + "type": "function" + }, + "tools": [ + { + "function": { + "description": "get weather", + "name": "get_weather", + "parameters": { + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ], + "type": "object" + } + }, + "type": "function" + }, + { + "function": { + "description": "stock quote", + "name": "lookup_stock", + "parameters": { + "properties": { + "symbol": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json new file mode 100644 index 0000000..4a016f5 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json @@ -0,0 +1,74 @@ +{ + "completed_at": 0, + "conversation": null, + "created_at": 0, + "error": null, + "id": "\u003cscrubbed\u003e", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "metadata": {}, + "model": "origin-model", + "object": "response", + "output": [ + { + "id": "\u003cscrubbed\u003e", + "summary": [ + { + "text": "CAP_MARKER_THINKING user asks weather", + "type": "summary_text" + } + ], + "type": "reasoning" + }, + { + "content": [ + { + "text": "CAP_MARKER_ASSISTANT_TEXT the weather is 68F", + "type": "output_text" + } + ], + "id": "\u003cscrubbed\u003e", + "role": "assistant", + "status": "completed", + "type": "message" + }, + { + "arguments": "{\"location\": \"Boston\"}", + "call_id": "call_1", + "id": "\u003cscrubbed\u003e", + "name": "get_weather", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt": null, + "reasoning": { + "effort": null, + "summary": null + }, + "status": "completed", + "store": false, + "temperature": 1, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [], + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 100, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": {}, + "total_tokens": 150 + }, + "user": null +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json new file mode 100644 index 0000000..8826d6f --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/reasoning-roundtrip.request.json @@ -0,0 +1,65 @@ +{ + "messages": [ + { + "content": "You are a coding agent.", + "role": "system" + }, + { + "content": "list files", + "role": "user" + }, + { + "content": null, + "reasoning_content": "先用 shell 工具查看目录结构。", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"cmd\":\"ls\"}", + "name": "shell" + }, + "id": "call_1", + "type": "function" + } + ] + }, + { + "content": "Chunk ID: ab12cd\nOutput:\na.go b.go", + "role": "tool", + "tool_call_id": "call_1" + }, + { + "content": "目录里有两个 Go 文件。", + "reasoning_content": "根据目录内容回复用户。", + "role": "assistant" + }, + { + "content": "open a.go", + "role": "user" + } + ], + "model": "upstream-model", + "stream": true, + "stream_options": { + "include_usage": true + }, + "tools": [ + { + "function": { + "name": "shell", + "parameters": { + "properties": { + "cmd": { + "type": "string" + } + }, + "required": [ + "cmd" + ], + "type": "object" + } + }, + "type": "function" + } + ] +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json new file mode 100644 index 0000000..df25059 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/thinking.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 4096, + "messages": [ + { + "content": "Prove that sqrt(2) is irrational.", + "role": "user" + } + ], + "model": "upstream-model", + "reasoning_effort": "high" +} diff --git a/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json new file mode 100644 index 0000000..5c41e04 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/websearch.request.json @@ -0,0 +1,11 @@ +{ + "max_tokens": 1024, + "messages": [ + { + "content": "Search the latest AI news.", + "role": "user" + } + ], + "model": "upstream-model", + "web_search_options": {} +} diff --git a/relaykit/relayconvert/register/golden/inputs/claude__basic.json b/relaykit/relayconvert/register/golden/inputs/claude__basic.json new file mode 100644 index 0000000..74d10ce --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__basic.json @@ -0,0 +1,9 @@ +{ + "model": "origin-model", + "max_tokens": 256, + "system": "You are helpful.", + "messages": [ + {"role": "user", "content": "Hello, how are you?"} + ], + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/inputs/claude__full.json b/relaykit/relayconvert/register/golden/inputs/claude__full.json new file mode 100644 index 0000000..ff89f8f --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__full.json @@ -0,0 +1,27 @@ +{ + "model": "origin-model", + "max_tokens": 1024, + "system": "You are a weather and finance assistant.", + "messages": [ + {"role": "user", "content": [ + {"type": "text", "text": "Describe this image and check the weather in Boston."}, + {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "assistant", "content": [ + {"type": "text", "text": "checking weather"}, + {"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {"location": "Boston"}} + ]}, + {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_1", "content": "68F, sunny"} + ]}, + {"role": "user", "content": "Thanks! What about the stock price of ACME?"} + ], + "tools": [ + {"name": "get_weather", "description": "get weather", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"name": "lookup_stock", "description": "stock quote", "input_schema": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ], + "tool_choice": {"type": "tool", "name": "get_weather"}, + "stop_sequences": ["END_TOKEN"], + "temperature": 0.7, + "stream": true +} diff --git a/relaykit/relayconvert/register/golden/inputs/claude__thinking.json b/relaykit/relayconvert/register/golden/inputs/claude__thinking.json new file mode 100644 index 0000000..f2b78b2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__thinking.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "max_tokens": 4096, + "messages": [{"role": "user", "content": "Prove that sqrt(2) is irrational."}], + "thinking": {"type": "enabled", "budget_tokens": 2048} +} diff --git a/relaykit/relayconvert/register/golden/inputs/claude__websearch.json b/relaykit/relayconvert/register/golden/inputs/claude__websearch.json new file mode 100644 index 0000000..2ac538a --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__websearch.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Search the latest AI news."}], + "tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}] +} diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__basic.json b/relaykit/relayconvert/register/golden/inputs/gemini__basic.json new file mode 100644 index 0000000..7eafb86 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/gemini__basic.json @@ -0,0 +1,7 @@ +{ + "systemInstruction": {"parts": [{"text": "You are helpful."}]}, + "contents": [ + {"role": "user", "parts": [{"text": "Hello, how are you?"}]} + ], + "generationConfig": {"maxOutputTokens": 256, "temperature": 0.7} +} diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__full.json b/relaykit/relayconvert/register/golden/inputs/gemini__full.json new file mode 100644 index 0000000..38ee501 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/gemini__full.json @@ -0,0 +1,25 @@ +{ + "systemInstruction": {"parts": [{"text": "You are a weather and finance assistant."}]}, + "contents": [ + {"role": "user", "parts": [ + {"text": "Describe this image and check the weather in Boston."}, + {"inlineData": {"mimeType": "image/jpeg", "data": "/9j/4AAQSkZJRg=="}} + ]}, + {"role": "model", "parts": [ + {"text": "checking weather"}, + {"functionCall": {"name": "get_weather", "args": {"location": "Boston"}}} + ]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "get_weather", "response": {"result": "68F, sunny"}}} + ]}, + {"role": "user", "parts": [{"text": "Thanks! What about the stock price of ACME?"}]} + ], + "tools": [ + {"functionDeclarations": [ + {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ]} + ], + "toolConfig": {"functionCallingConfig": {"mode": "ANY", "allowedFunctionNames": ["get_weather"]}}, + "generationConfig": {"maxOutputTokens": 1024, "stopSequences": ["END_TOKEN"], "temperature": 0.7} +} diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json b/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json new file mode 100644 index 0000000..d55a945 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/gemini__thinking.json @@ -0,0 +1,4 @@ +{ + "contents": [{"role": "user", "parts": [{"text": "Prove that sqrt(2) is irrational."}]}], + "generationConfig": {"maxOutputTokens": 4096, "thinkingConfig": {"includeThoughts": true, "thinkingBudget": 2048}} +} diff --git a/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json b/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json new file mode 100644 index 0000000..f2803b6 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/gemini__websearch.json @@ -0,0 +1,5 @@ +{ + "contents": [{"role": "user", "parts": [{"text": "Search the latest AI news."}]}], + "tools": [{"googleSearch": {}}], + "generationConfig": {"maxOutputTokens": 1024} +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai__basic.json b/relaykit/relayconvert/register/golden/inputs/openai__basic.json new file mode 100644 index 0000000..c3b3eef --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai__basic.json @@ -0,0 +1,9 @@ +{ + "model": "origin-model", + "messages": [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello, how are you?"} + ], + "temperature": 0.7, + "max_tokens": 256 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai__full.json b/relaykit/relayconvert/register/golden/inputs/openai__full.json new file mode 100644 index 0000000..40e1e96 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai__full.json @@ -0,0 +1,24 @@ +{ + "model": "origin-model", + "messages": [ + {"role": "system", "content": "You are a weather and finance assistant."}, + {"role": "user", "content": [ + {"type": "text", "text": "Describe this image and check the weather in Boston."}, + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="}} + ]}, + {"role": "assistant", "content": null, "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "68F, sunny"}, + {"role": "user", "content": "Thanks! What about the stock price of ACME?"} + ], + "tools": [ + {"type": "function", "function": {"name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}}, + {"type": "function", "function": {"name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}}} + ], + "tool_choice": {"type": "function", "function": {"name": "get_weather"}}, + "max_tokens": 1024, + "stop": ["END_TOKEN"], + "temperature": 0.7, + "stream": true +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json b/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json new file mode 100644 index 0000000..2176dc6 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai__schema-variants.json @@ -0,0 +1,43 @@ +{ + "model": "origin-model", + "messages": [ + {"role": "user", "content": "Search the repo with the where filter."} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "grep", + "description": "search repo", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "where": { + "type": "array", + "maxItems": 10, + "items": { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"enum": ["AND", "OR"], "type": "string"}, + {} + ] + } + }, + "paths": {"type": "array"}, + "tag": {"anyOf": [{"type": "string"}, {"type": "number"}]} + }, + "required": ["pattern"] + } + }, + "required": ["query"] + } + } + } + ], + "max_tokens": 512 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai__thinking.json b/relaykit/relayconvert/register/golden/inputs/openai__thinking.json new file mode 100644 index 0000000..7f87165 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai__thinking.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "messages": [{"role": "user", "content": "Prove that sqrt(2) is irrational."}], + "reasoning_effort": "high", + "max_tokens": 4096 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai__websearch.json b/relaykit/relayconvert/register/golden/inputs/openai__websearch.json new file mode 100644 index 0000000..64f89bc --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai__websearch.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "messages": [{"role": "user", "content": "Search the latest AI news."}], + "web_search_options": {}, + "max_tokens": 1024 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json new file mode 100644 index 0000000..84abd8a --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__basic.json @@ -0,0 +1,9 @@ +{ + "model": "origin-model", + "instructions": "You are helpful.", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Hello, how are you?"}]} + ], + "max_output_tokens": 256, + "temperature": 0.7 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json new file mode 100644 index 0000000..894875a --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__full.json @@ -0,0 +1,21 @@ +{ + "model": "origin-model", + "instructions": "You are a weather and finance assistant.", + "input": [ + {"role": "user", "content": [ + {"type": "input_text", "text": "Describe this image and check the weather in Boston."}, + {"type": "input_image", "image_url": "data:image/jpeg;base64,/9j/4AAQSkZJRg=="} + ]}, + {"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"location\": \"Boston\"}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "68F, sunny"}, + {"role": "user", "content": [{"type": "input_text", "text": "Thanks! What about the stock price of ACME?"}]} + ], + "tools": [ + {"type": "function", "name": "get_weather", "description": "get weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]}}, + {"type": "function", "name": "lookup_stock", "description": "stock quote", "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}}} + ], + "tool_choice": {"type": "function", "name": "get_weather"}, + "max_output_tokens": 1024, + "temperature": 0.7, + "stream": true +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json new file mode 100644 index 0000000..b1f570a --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__reasoning-roundtrip.json @@ -0,0 +1,17 @@ +{ + "model": "origin-model", + "instructions": "You are a coding agent.", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "list files"}]}, + {"type": "reasoning", "content": [{"type": "reasoning_text", "text": "先用 shell 工具查看目录结构。"}]}, + {"type": "function_call", "call_id": "call_1", "name": "shell", "arguments": "{\"cmd\":\"ls\"}"}, + {"type": "function_call_output", "call_id": "call_1", "output": "Chunk ID: ab12cd\nOutput:\na.go b.go"}, + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "根据目录内容回复用户。"}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "目录里有两个 Go 文件。"}]}, + {"role": "user", "content": [{"type": "input_text", "text": "open a.go"}]} + ], + "tools": [ + {"type": "function", "name": "shell", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}, "required": ["cmd"]}} + ], + "stream": true +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json new file mode 100644 index 0000000..fa1d0ae --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__thinking.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Prove that sqrt(2) is irrational."}]}], + "reasoning": {"effort": "high"}, + "max_output_tokens": 4096 +} diff --git a/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json b/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json new file mode 100644 index 0000000..d90cf26 --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/openai_responses__websearch.json @@ -0,0 +1,6 @@ +{ + "model": "origin-model", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Search the latest AI news."}]}], + "tools": [{"type": "web_search"}], + "max_output_tokens": 1024 +} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt new file mode 100644 index 0000000..af29f32 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__basic.txt @@ -0,0 +1,20 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":1,"cache_read_input_tokens":4}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world!"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":7}} + +event: message_stop +data: {"type":"message_stop"} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt new file mode 100644 index 0000000..6b4d5e9 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__thinking.txt @@ -0,0 +1,29 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think..."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_abc123"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"The answer is 42."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":50}} + +event: message_stop +data: {"type":"message_stop"} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt new file mode 100644 index 0000000..9471884 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/claude__tools.txt @@ -0,0 +1,29 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_up","type":"message","role":"assistant","content":[],"model":"upstream-model","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":20,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me check the weather."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"Shanghai\"}"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}} + +event: message_stop +data: {"type":"message_stop"} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt new file mode 100644 index 0000000..a204657 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__basic.txt @@ -0,0 +1,5 @@ +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Hello"}]}}]} + +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":", world!"}]}}]} + +data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":7,"totalTokenCount":19,"cachedContentTokenCount":4}} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt new file mode 100644 index 0000000..520d3da --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__thinking.txt @@ -0,0 +1,5 @@ +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"thought":true,"text":"Let me think...","thoughtSignature":"sig_abc123"}]}}]} + +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"The answer is 42."}]}}]} + +data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":50,"totalTokenCount":60,"thoughtsTokenCount":30}} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt new file mode 100644 index 0000000..f32ffaa --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/gemini__tools.txt @@ -0,0 +1,5 @@ +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Let me check the weather."}]}}]} + +data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Shanghai"}}}]}}]} + +data: {"candidates":[{"index":0,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":9,"totalTokenCount":29}} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt new file mode 100644 index 0000000..72a6a3f --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__basic.txt @@ -0,0 +1,3 @@ +{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"Hello"},"done":false} +{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":", world!"},"done":false} +{"model":"upstream-model","created_at":"2026-01-01T00:00:02Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":12,"eval_count":7} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt new file mode 100644 index 0000000..949e0b4 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__thinking.txt @@ -0,0 +1,3 @@ +{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"","thinking":"Let me think..."},"done":false} +{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":"The answer is 42."},"done":false} +{"model":"upstream-model","created_at":"2026-01-01T00:00:02Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":10,"eval_count":50} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt new file mode 100644 index 0000000..8d5a750 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/ollama__tools.txt @@ -0,0 +1,2 @@ +{"model":"upstream-model","created_at":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Shanghai"}}}]},"done":false} +{"model":"upstream-model","created_at":"2026-01-01T00:00:01Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":5000000000,"prompt_eval_count":20,"eval_count":9} diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt new file mode 100644 index 0000000..b05c8d2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__basic.txt @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19,"prompt_tokens_details":{"cached_tokens":4}}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt new file mode 100644 index 0000000..f3dcb4b --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__thinking.txt @@ -0,0 +1,7 @@ +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me think..."},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"content":"The answer is 42."},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60,"completion_tokens_details":{"reasoning_tokens":30}}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt new file mode 100644 index 0000000..16b3f1d --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai__tools.txt @@ -0,0 +1,9 @@ +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Shanghai\"}"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-up","object":"chat.completion.chunk","created":1700000000,"model":"upstream-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":9,"total_tokens":29}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt new file mode 100644 index 0000000..2548267 --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__basic.txt @@ -0,0 +1,16 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_up","status":"in_progress","role":"assistant","content":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":"Hello"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":", world!"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":12,"output_tokens":7,"total_tokens":19,"input_tokens_details":{"cached_tokens":4}}}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt new file mode 100644 index 0000000..55d69ab --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__thinking.txt @@ -0,0 +1,13 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"msg_up","output_index":0,"summary_index":0,"delta":"Let me think..."} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_up","output_index":0,"content_index":0,"delta":"The answer is 42."} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":10,"output_tokens":50,"total_tokens":60,"output_tokens_details":{"reasoning_tokens":30}}}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt new file mode 100644 index 0000000..dc886fe --- /dev/null +++ b/relaykit/relayconvert/register/golden/stream_inputs/openai_responses__tools.txt @@ -0,0 +1,16 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"in_progress","model":"upstream-model"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"get_weather","arguments":""}} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"{\"city\":"} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","item_id":"call_1","output_index":0,"delta":"\"Shanghai\"}"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_up","object":"response","created_at":1700000000,"status":"completed","model":"upstream-model","usage":{"input_tokens":20,"output_tokens":9,"total_tokens":29}}} + +data: [DONE] diff --git a/relaykit/relayconvert/register/goldensuite_stream_test.go b/relaykit/relayconvert/register/goldensuite_stream_test.go new file mode 100644 index 0000000..890bc34 --- /dev/null +++ b/relaykit/relayconvert/register/goldensuite_stream_test.go @@ -0,0 +1,267 @@ +package register + +// goldensuite_stream_test.go — 注册表级流式金样本套件(全部流式方向)。 +// +// 与非流式套件(goldensuite_test.go)同构:一套共享上游流语料 +// (golden/stream_inputs/<上游格式>__<场景>.txt,SSE / NDJSON wire 原文)由 +// ListStreamConverterSpecs 枚举驱动全部已注册流式方向,新注册方向零成本纳入。 +// 期望输出(帧序列)按转换器落盘在 golden/expected/<转换器ID>/<场景>.stream.json。 +// +// 帧序列表达:每帧 {event?, data, usage?}—— +// - OpenAI chat chunk(*dto.ChatCompletionStreamResponse):无 event,data 为 chunk 本体; +// - StreamEvent:event 为事件名(Claude/Responses 风格;Gemini 风格为空省略), +// usage 为宿主将捕获的计费用量(billing 契约的一部分,必须锁进金样本)。 +// +// 工作流与非流式套件一致: +// - 首次生成 / 确认新行为:go test -run TestGoldenSuite -update-golden ./relayconvert/register/ +// - 日常回归:输出变化 → 比对失败;符合预期则 -update-golden 后 git diff 审查变化面。 +// +// 另含 TestStreamInvariants_ToolsAndUsage:不锁具体形状,只断言每个方向的语义底线 +// (工具名不丢、用量可捕获、终止语义符合客户端协议),金样本重生成也绕不过它。 + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/dto" + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/types" + "github.com/stretchr/testify/require" +) + +// streamScenarios 流式场景清单;某上游格式缺少某场景语料时该组合自动跳过。 +var streamScenarios = []string{"basic", "tools", "thinking"} + +// streamFrame 一帧转换输出的金样本形态。 +type streamFrame struct { + Event string `json:"event,omitempty"` + Data any `json:"data"` + Usage any `json:"usage,omitempty"` +} + +// streamStats 转换过程的语义统计(供不依赖金样本形状的守恒断言使用)。 +type streamStats struct { + usageSeen bool // 出现过宿主可捕获的用量(chat chunk Usage 或 StreamEvent.Usage) + finishSeen bool // chat chunk 出现过非空 finish_reason + eventNames []string // StreamEvent 事件名序列(Gemini 风格空名也计入,为空串) + chatChunkCount int +} + +// runStreamConverter 以注册表查到的流式转换器消费语料,收集帧序列与语义统计。 +func runStreamConverter(t *testing.T, converterID string, corpus []byte) ([]streamFrame, *streamStats) { + t.Helper() + + fn, ok := relayconvert.LookupStreamConverterByID(converterID) + require.True(t, ok, "流式转换器 %s 未注册", converterID) + + var frames []streamFrame + stats := &streamStats{} + err := fn(context.Background(), capMeta(), bytes.NewReader(corpus), func(chunk any) error { + switch c := chunk.(type) { + case *dto.ChatCompletionStreamResponse: + stats.chatChunkCount++ + if c.Usage != nil { + stats.usageSeen = true + } + for _, choice := range c.Choices { + if choice.FinishReason != nil { + stats.finishSeen = true + } + } + frames = append(frames, streamFrame{Data: c}) + case *relayconvert.StreamEvent: + if c == nil { + return nil + } + stats.eventNames = append(stats.eventNames, c.Event) + f := streamFrame{Event: c.Event, Data: c.Data} + if c.Usage != nil { + stats.usageSeen = true + f.Usage = c.Usage + } + frames = append(frames, f) + default: + t.Errorf("流式转换器 %s 产出未知 chunk 类型 %T(宿主桥接层会静默丢弃)", converterID, chunk) + } + return nil + }) + require.NoError(t, err, "流式转换失败") + return frames, stats +} + +func TestGoldenSuite_Streams(t *testing.T) { + for _, spec := range relayconvert.ListStreamConverterSpecs() { + for _, scenario := range streamScenarios { + inPath := filepath.Join("golden", "stream_inputs", string(spec.From)+"__"+scenario+".txt") + corpus, err := os.ReadFile(inPath) + if os.IsNotExist(err) { + continue // 该上游格式无此场景语料 + } + require.NoError(t, err) + + t.Run(spec.ID+"/"+scenario, func(t *testing.T) { + frames, _ := runStreamConverter(t, spec.ID, corpus) + require.NotEmpty(t, frames, "转换器未产出任何帧") + + // 帧序列 → 通用 JSON 树 → 清洗非确定字段,再走与非流式一致的比对/重生流程 + raw, err := json.Marshal(frames) + require.NoError(t, err) + var tree any + require.NoError(t, json.Unmarshal(raw, &tree)) + tree = scrubStreamNondeterministic(tree) + + compareOrUpdateGolden(t, + filepath.Join("golden", "expected", spec.ID, scenario+".stream.json"), + tree, false) + }) + } + } +} + +// synthesizedStreamID 流式转换器按时间戳合成的 ID 形态(resp_/msg_/fc_/chatcmpl- 前缀 +// 接毫秒/纳秒时间戳,或 call_[_])。透传自语料的短 ID(msg_up / toolu_1 / +// call_1 等)不匹配、不清洗,保持可断言。 +var synthesizedStreamID = regexp.MustCompile(`^(?:resp_|msg_|fc_|chatcmpl-)\d{10,}$|^call_\d{10,}(?:_\d+)?$`) + +// scrubStreamNondeterministic 递归清洗流式帧树中的非确定字段: +// - 命中 nondeterministicKeys 的 key(id/created/created_at/completed_at 等)按类型置占位; +// - 任意位置的字符串值若匹配合成 ID 模式(如 item_id 里的 msg_<纳秒>)也置占位。 +func scrubStreamNondeterministic(v any) any { + switch node := v.(type) { + case map[string]any: + for k, val := range node { + if nondeterministicKeys[k] { + switch val.(type) { + case string: + node[k] = "" + case float64: + node[k] = 0 + } + continue + } + node[k] = scrubStreamNondeterministic(val) + } + return node + case []any: + for i := range node { + node[i] = scrubStreamNondeterministic(node[i]) + } + return node + case string: + if synthesizedStreamID.MatchString(node) { + return "" + } + return node + default: + return v + } +} + +// TestStreamInvariants_ToolsAndUsage 流式语义守恒:对每个已注册流式方向灌入 tools +// 语料(含工具调用 + 用量 + 正常收尾的上游流),断言与金样本形状无关的语义底线: +// +// 1. 工具调用名不得静默丢失(转换后帧序列中必须出现 get_weather); +// 2. 用量必须以宿主可捕获的形态产出(chat chunk 的 Usage 或 StreamEvent.Usage), +// 否则计费按 0 token 结算; +// 3. 终止语义符合客户端协议:OpenAI 需带 finish_reason 的 chunk、Claude 需 +// message_stop 事件、Responses 需 response.completed 事件、Gemini 需 finishReason 字段。 +// +// 每个上游格式必须提供 tools 语料(新增上游流格式时在 stream_inputs 补齐)。 +func TestStreamInvariants_ToolsAndUsage(t *testing.T) { + for _, spec := range relayconvert.ListStreamConverterSpecs() { + t.Run(spec.ID, func(t *testing.T) { + corpus, err := os.ReadFile(filepath.Join("golden", "stream_inputs", string(spec.From)+"__tools.txt")) + require.NoError(t, err, "上游格式 %s 缺少 tools 流式语料,请在 golden/stream_inputs/ 补充", spec.From) + + frames, stats := runStreamConverter(t, spec.ID, corpus) + require.NotEmpty(t, frames) + + raw, err := json.Marshal(frames) + require.NoError(t, err) + require.Contains(t, string(raw), "get_weather", "工具调用名在流式转换后丢失(静默能力丢失)") + + require.True(t, stats.usageSeen, "未产出宿主可捕获的用量(计费将按 0 token 结算)") + + switch spec.To { + case types.RelayFormatOpenAI: + require.True(t, stats.finishSeen, "OpenAI 客户端方向未产出带 finish_reason 的终止 chunk") + case types.RelayFormatClaude: + require.Contains(t, stats.eventNames, "message_stop", "Claude 客户端方向未产出 message_stop 终止事件") + case types.RelayFormatOpenAIResponses: + require.Contains(t, stats.eventNames, "response.completed", "Responses 客户端方向未产出 response.completed 终止事件") + case types.RelayFormatGemini: + require.Contains(t, string(raw), "finishReason", "Gemini 客户端方向未产出带 finishReason 的终止帧") + default: + t.Errorf("未支持的客户端格式 %s,请补充终止语义断言", spec.To) + } + }) + } +} + +// TestGoldenSuite_StreamInputCorpusRegistered 流式语料目录完整性:stream_inputs 下的 +// 文件名必须能被套件识别(<已知上游格式>__<已知场景>.txt),防止拼错文件名被静默跳过。 +func TestGoldenSuite_StreamInputCorpusRegistered(t *testing.T) { + knownFormats := map[string]bool{ + string(types.RelayFormatOpenAI): true, + string(types.RelayFormatClaude): true, + string(types.RelayFormatGemini): true, + string(types.RelayFormatOpenAIResponses): true, + string(types.RelayFormatOllama): true, + } + knownScenarios := map[string]bool{} + for _, s := range streamScenarios { + knownScenarios[s] = true + } + + files, err := os.ReadDir(filepath.Join("golden", "stream_inputs")) + require.NoError(t, err) + for _, f := range files { + name := strings.TrimSuffix(f.Name(), ".txt") + parts := strings.SplitN(name, "__", 2) + if len(parts) != 2 || !knownFormats[parts[0]] || !knownScenarios[parts[1]] { + t.Errorf("流式语料文件 %s 不符合 <上游格式>__<场景>.txt 命名(格式∈%v,场景∈%v),会被套件静默跳过", + f.Name(), keysOf(knownFormats), streamScenarios) + } + } +} + +// geminiGroundedStream 带 groundingMetadata 的 Gemini 上游流: +// grounding 只在**末帧**到达,正文增量此前早已推给客户端。 +const geminiGroundedStream = `data: {"candidates":[{"index":0,"content":{"role":"model","parts":[{"text":"Boston is 68F"}]}}]} + +data: {"candidates":[{"index":0,"finishReason":"STOP","groundingMetadata":{"webSearchQueries":["boston weather"],"groundingChunks":[{"web":{"uri":"https://weather.example/boston","title":"weather.example"}}],"groundingSupports":[{"segment":{"startIndex":0,"endIndex":6,"text":"Boston"},"groundingChunkIndices":[0]}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}} + +` + +// TestStreamInvariants_GroundingRestore 流式响应侧搜索证据还原: +// Gemini 上游在末帧给出 groundingMetadata 时,来源 URL 必须出现在客户端收到的流里。 +// +// 这是非流式 grounding 还原(TestCapabilityInvariants_GroundingRestore)的流式对照。 +// 流式的难点在于时序:证据到得比正文晚,转换器必须缓冲并在收尾补发, +// 漏做就会出现「非流式有引用、流式没有」的不一致——而客户端通常只用流式。 +func TestStreamInvariants_GroundingRestore(t *testing.T) { + for _, spec := range relayconvert.ListStreamConverterSpecs() { + if spec.From != types.RelayFormatGemini { + continue // grounding 是 Gemini 侧构件 + } + + t.Run(spec.ID, func(t *testing.T) { + frames, _ := runStreamConverter(t, spec.ID, []byte(geminiGroundedStream)) + require.NotEmpty(t, frames) + + raw, err := json.Marshal(frames) + require.NoError(t, err) + body := string(raw) + + require.Contains(t, body, "https://weather.example/boston", + "流式转换后搜索来源丢失(非流式已还原、流式漏做会导致两者行为不一致)\n实际帧序列: %s", body) + require.Contains(t, body, "Boston is 68F", "回答正文在 grounding 还原后丢失") + }) + } +} diff --git a/relaykit/relayconvert/register/goldensuite_test.go b/relaykit/relayconvert/register/goldensuite_test.go new file mode 100644 index 0000000..e53bd9f --- /dev/null +++ b/relaykit/relayconvert/register/goldensuite_test.go @@ -0,0 +1,212 @@ +package register + +// goldensuite_test.go — 注册表级金样本套件(全方向请求 + 响应)。 +// +// 与各转换器包内的 golden_test 不同,本套件由注册表驱动:一套共享场景语料 +// (golden/inputs/<入站格式>__<场景>.json,wire JSON)自动覆盖所有已注册方向, +// 新注册方向零成本纳入。期望输出按转换器落盘在 +// golden/expected/<转换器ID>/<场景>.request.json(响应侧 .response.json)。 +// +// 工作流: +// - 首次生成 / 确认新行为:go test -run TestGoldenSuite -update-golden ./relayconvert/register/ +// - 日常回归:任何改动导致某方向输出变化,对应 expected 文件比对失败; +// 若变化符合预期,重新 -update-golden 后 git diff 审查 expected 文件的变化面—— +// 改 A 方向却看到 B 方向的 expected 变化,即为意外的跨方向影响。 +// - 语料扩充:把真实客户端流量(脱敏后)按场景命名放进 golden/inputs/ 即自动生效。 +// +// 响应侧非确定字段(合成的 id / created 时间戳等)在比对前于全树置为固定占位符。 + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/qianfree/team-api/relaykit/relayconvert" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/goldentest" + "github.com/qianfree/team-api/relaykit/types" + "github.com/stretchr/testify/require" +) + +// goldenScenarios 请求场景清单;某入站格式缺少某场景语料时该组合自动跳过。 +// schema-variants / reasoning-roundtrip 来自线上真实 bug 形状的反哺(2026-09-12 语料战役, +// 见 docs/codex请求问题排查记录.md):前者是 Claude Code Grep 工具的 prefixItems 元组 +// + 嵌套数组 + 空 schema 节点,后者是 codex 多轮历史的明文 reasoning 项双形态回传。 +var goldenScenarios = []string{"basic", "full", "websearch", "thinking", "schema-variants", "reasoning-roundtrip"} + +// nondeterministicKeys 响应侧转换器合成的非确定字段(任意层级,按 key 名匹配)。 +// 比对前统一置为占位符,保证金样本可重复。 +var nondeterministicKeys = map[string]bool{ + "id": true, + "created": true, + "created_at": true, + "completed_at": true, + "responseId": true, + "response_id": true, + // call_id 不整体清洗:透传自输入的具确定性;仅合成形态按 synthesizedCallID 模式清洗 +} + +func TestGoldenSuite_Requests(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListRequestConverterIDs() { + spec, ok := relayconvert.LookupRequestConverter(id) + require.True(t, ok) + + for _, scenario := range goldenScenarios { + inPath := filepath.Join("golden", "inputs", string(spec.From)+"__"+scenario+".json") + input, err := os.ReadFile(inPath) + if os.IsNotExist(err) { + continue // 该入站格式无此场景语料 + } + require.NoError(t, err) + + t.Run(id+"/"+scenario, func(t *testing.T) { + parsed := parseWireRequest(t, spec.From, string(input)) + converted, convErr := relayconvert.ConvertRequestByID(ctx, capMeta(), id, parsed) + require.NoError(t, convErr, "转换失败") + compareOrUpdateGolden(t, + filepath.Join("golden", "expected", id, scenario+".request.json"), + converted, false) + }) + } + } +} + +func TestGoldenSuite_Responses(t *testing.T) { + ctx := context.Background() + + for _, id := range relayconvert.ListResponseConverterIDs() { + spec, ok := relayconvert.LookupResponseConverter(id) + require.True(t, ok) + if spec.Convert == nil { + continue // 纯流式登记不在非流式金样本范围 + } + + input, hasInput := upstreamWireResponses[spec.To] + if !hasInput { + t.Errorf("响应转换器 %s 的上游格式 %s 没有响应语料", id, spec.To) + continue + } + + t.Run(id, func(t *testing.T) { + parsed := parseWireResponse(t, spec.To, input) + converted, _, convErr := spec.Convert(ctx, capMeta(), parsed) + require.NoError(t, convErr, "响应转换失败") + compareOrUpdateGolden(t, + filepath.Join("golden", "expected", id, "full.response.json"), + converted, true) + }) + } +} + +// compareOrUpdateGolden 将转换结果与金样本文件比对;-update-golden 时重写文件。 +// scrub=true 时先将全树非确定字段置为占位符(响应侧)。 +func compareOrUpdateGolden(t *testing.T, path string, converted any, scrub bool) { + t.Helper() + + raw, err := json.Marshal(converted) + require.NoError(t, err, "marshal 转换结果") + var actual any + require.NoError(t, json.Unmarshal(raw, &actual)) + if scrub { + actual = scrubNondeterministic(actual) + } + + if *goldentest.Update { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + data, err := json.MarshalIndent(actual, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(data, '\n'), 0o644)) + t.Logf("金样本已更新: %s", path) + return + } + + expectedRaw, err := os.ReadFile(path) + if os.IsNotExist(err) { + t.Fatalf("金样本 %s 不存在,首次生成请运行: go test -run TestGoldenSuite -update-golden ./relayconvert/register/", path) + } + require.NoError(t, err) + var expected any + require.NoError(t, json.Unmarshal(expectedRaw, &expected)) + + if !goldentest.Equal(actual, expected) { + actualPretty, _ := json.MarshalIndent(actual, "", " ") + t.Errorf("转换输出与金样本 %s 不一致。\n若变更符合预期,运行 -update-golden 重生并在 git diff 中审查变化面。\n实际输出:\n%s", path, actualPretty) + } +} + +// synthesizedCallID 匹配转换器合成的 call_id(如 Gemini functionCall 无 id 时按 +// 纳秒时间戳生成 call__);透传自输入的 call_id(call_1 / toolu_1 等)不清洗。 +var synthesizedCallID = regexp.MustCompile(`^call_\d{15,}_\d+$`) + +// scrubNondeterministic 递归将非确定字段置为固定占位符。 +// 只处理值为字符串/数字的命中 key,避免误伤同名的结构字段。 +func scrubNondeterministic(v any) any { + switch node := v.(type) { + case map[string]any: + for k, val := range node { + if nondeterministicKeys[k] { + switch val.(type) { + case string: + node[k] = "" + case float64: + node[k] = 0 + } + continue + } + if k == "call_id" { + if s, ok := val.(string); ok && synthesizedCallID.MatchString(s) { + node[k] = "" + continue + } + } + node[k] = scrubNondeterministic(val) + } + return node + case []any: + for i := range node { + node[i] = scrubNondeterministic(node[i]) + } + return node + default: + return v + } +} + +// TestGoldenSuite_InputCorpusRegistered 语料目录完整性:inputs 下的文件名必须能被 +// 套件识别(<已知格式>__<已知场景>.json),防止拼错文件名导致语料被静默跳过。 +func TestGoldenSuite_InputCorpusRegistered(t *testing.T) { + knownFormats := map[string]bool{ + string(types.RelayFormatOpenAI): true, + string(types.RelayFormatClaude): true, + string(types.RelayFormatGemini): true, + string(types.RelayFormatOpenAIResponses): true, + } + knownScenarios := map[string]bool{} + for _, s := range goldenScenarios { + knownScenarios[s] = true + } + + files, err := os.ReadDir(filepath.Join("golden", "inputs")) + require.NoError(t, err) + for _, f := range files { + name := strings.TrimSuffix(f.Name(), ".json") + parts := strings.SplitN(name, "__", 2) + if len(parts) != 2 || !knownFormats[parts[0]] || !knownScenarios[parts[1]] { + t.Errorf("语料文件 %s 不符合 <格式>__<场景>.json 命名(格式∈%v,场景∈%v),会被套件静默跳过", + f.Name(), keysOf(knownFormats), goldenScenarios) + } + } +} + +func keysOf(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/relaykit/relayconvert/register/register.go b/relaykit/relayconvert/register/register.go index 6d93c27..e6373d9 100644 --- a/relaykit/relayconvert/register/register.go +++ b/relaykit/relayconvert/register/register.go @@ -16,10 +16,11 @@ import ( "github.com/qianfree/team-api/relaykit/dto" "github.com/qianfree/team-api/relaykit/relayconvert" "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" - "github.com/qianfree/team-api/relaykit/relayconvert/internal/coze_chat" - "github.com/qianfree/team-api/relaykit/relayconvert/internal/dify_chat" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/claude_gemini" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/native_responses" "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_chat" "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_gemini" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/oai_responses" "github.com/qianfree/team-api/relaykit/relayconvert/internal/ollama_chat" "github.com/qianfree/team-api/relaykit/types" ) @@ -28,9 +29,11 @@ func init() { registerOpenAIToClaude() registerOpenAIToGemini() // 剩余原生格式供应商 - registerOpenAIToCoze() - registerOpenAIToDify() registerOpenAIToOllama() + // 反向方向(非 OpenAI 入站 → OpenAI 上游)与 Responses 双向 + registerReverseAndResponsesDirections() + // 跨原生方向(请求经 OpenAI 中枢链式,响应直连) + registerCrossNativeDirections() } // registerOpenAIToClaude 注册 OpenAI → Claude 方向转换器。 @@ -100,70 +103,6 @@ func registerOpenAIToGemini() { ) } -// registerOpenAIToCoze 注册 OpenAI → Coze 方向转换器。 -// 客户端说 OpenAI,上游说 Coze v3: -// - 请求侧 OpenAI → Coze -// - 响应侧 Coze → OpenAI(非流式:解析缓冲 SSE;流式:SSE→SSE) -func registerOpenAIToCoze() { - reqConv := &coze_chat.OpenAIToCozeRequestConverter{} - respConv := &coze_chat.CozeToOpenAIResponseConverter{} - - relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ - ID: relayconvert.ConverterOpenAIChatToCoze, - From: types.RelayFormatOpenAI, - To: types.RelayFormatCoze, - Quality: relayconvert.TextConverterQualityFair, - Req: relayconvert.TextRequestSide{ - Convert: reqConv.ConvertRequest, - }, - Resp: relayconvert.TextResponseSide{ - Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { - result, err := respConv.ConvertResponse(ctx, info, response) - return result, nil, err - }, - }, - }) - - // 流式响应侧:Coze SSE → OpenAI SSE(方向与请求相反)。 - relayconvert.RegisterStreamConverter( - types.RelayFormatCoze, types.RelayFormatOpenAI, - relayconvert.ResponseConverterCozeChatToOAIChatStream, - (&coze_chat.CozeToOpenAIStreamConverter{}).ConvertStreamResponse, - ) -} - -// registerOpenAIToDify 注册 OpenAI → Dify 方向转换器。 -// 客户端说 OpenAI,上游说 Dify chat-messages: -// - 请求侧 OpenAI → Dify -// - 响应侧 Dify → OpenAI(非流式 blocking JSON;流式 SSE→SSE) -func registerOpenAIToDify() { - reqConv := &dify_chat.OpenAIToDifyRequestConverter{} - respConv := &dify_chat.DifyToOpenAIResponseConverter{} - - relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ - ID: relayconvert.ConverterOpenAIChatToDify, - From: types.RelayFormatOpenAI, - To: types.RelayFormatDify, - Quality: relayconvert.TextConverterQualityFair, - Req: relayconvert.TextRequestSide{ - Convert: reqConv.ConvertRequest, - }, - Resp: relayconvert.TextResponseSide{ - Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { - result, err := respConv.ConvertResponse(ctx, info, response) - return result, nil, err - }, - }, - }) - - // 流式响应侧:Dify SSE → OpenAI SSE(方向与请求相反)。 - relayconvert.RegisterStreamConverter( - types.RelayFormatDify, types.RelayFormatOpenAI, - relayconvert.ResponseConverterDifyChatToOAIChatStream, - (&dify_chat.DifyToOpenAIStreamConverter{}).ConvertStreamResponse, - ) -} - // registerOpenAIToOllama 注册 OpenAI → Ollama 方向转换器(仅 chat 路径)。 // 客户端说 OpenAI,上游说 Ollama /api/chat: // - 请求侧 OpenAI → Ollama @@ -197,3 +136,271 @@ func registerOpenAIToOllama() { (&ollama_chat.OllamaToOpenAIStreamConverter{}).ConvertStreamResponse, ) } + +// registerReverseAndResponsesDirections 注册反向方向(非 OpenAI 入站 → OpenAI 上游) +// 与 Responses 双向转换器。 +// +// 单侧注册:反向方向的请求侧与响应侧分属不同的配对 spec(如 Claude 入站 → OpenAI 上游, +// 请求走 c2o、响应走 o2c),故按方向分别注册,注册表允许某一侧为空。 +func registerReverseAndResponsesDirections() { + registerClaudeToOpenAI() + registerGeminiToOpenAI() + registerResponsesToOpenAI() + registerOpenAIToResponses() +} + +// registerClaudeToOpenAI 注册 Claude 入站 → OpenAI 上游方向。 +// 请求侧 Claude → OpenAI;响应侧 OpenAI → Claude(含流式)。 +func registerClaudeToOpenAI() { + reqConv := &oai_chat.ClaudeToOpenAIRequestConverter{} + respConv := &oai_chat.OpenAIToClaudeResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterClaudeMessagesToOpenAIChat, + From: types.RelayFormatClaude, + To: types.RelayFormatOpenAI, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + Convert: reqConv.ConvertRequest, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + // 流式响应侧:OpenAI SSE → Claude 事件流(方向与请求相反)。 + relayconvert.RegisterStreamConverter( + types.RelayFormatOpenAI, types.RelayFormatClaude, + relayconvert.ResponseConverterOAIChatToClaudeMessagesStream, + (&oai_chat.OpenAIToClaudeStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerGeminiToOpenAI 注册 Gemini 入站 → OpenAI 上游方向。 +// 请求侧 Gemini → OpenAI;响应侧 OpenAI → Gemini(含流式)。 +func registerGeminiToOpenAI() { + reqConv := &oai_gemini.GeminiToOpenAIRequestConverter{} + respConv := &oai_gemini.OpenAIToGeminiResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterGeminiContentToOpenAIChat, + From: types.RelayFormatGemini, + To: types.RelayFormatOpenAI, + Quality: relayconvert.TextConverterQualityGood, + Req: relayconvert.TextRequestSide{ + Convert: reqConv.ConvertRequest, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + // 流式响应侧:OpenAI SSE → Gemini data 帧(方向与请求相反)。 + relayconvert.RegisterStreamConverter( + types.RelayFormatOpenAI, types.RelayFormatGemini, + relayconvert.ResponseConverterOAIChatToGeminiChatStream, + (&oai_gemini.OpenAIToGeminiStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerResponsesToOpenAI 注册 Responses 入站 → OpenAI chat 上游方向。 +// 请求侧 Responses → chat;响应侧 chat → Responses(含流式)。 +func registerResponsesToOpenAI() { + reqConv := &oai_responses.ResponsesToOpenAIRequestConverter{} + respConv := &oai_responses.OpenAIToResponsesResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterOpenAIResponsesToOpenAIChat, + From: types.RelayFormatOpenAIResponses, + To: types.RelayFormatOpenAI, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + Convert: reqConv.ConvertRequest, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + // 流式响应侧:OpenAI chat SSE → Responses 事件流(方向与请求相反)。 + relayconvert.RegisterStreamConverter( + types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, + relayconvert.ResponseConverterOAIChatToOAIResponsesStream, + (&oai_responses.OpenAIToResponsesStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerOpenAIToResponses 注册 OpenAI chat 入站 → Responses 上游方向 +// (渠道 ChatViaResponses 桥接:chat 请求经 /v1/responses 发送)。 +// 请求侧 chat → Responses;响应侧 Responses → chat(含流式)。 +func registerOpenAIToResponses() { + reqConv := &oai_responses.OpenAIToResponsesRequestConverter{} + respConv := &oai_responses.ResponsesToOpenAIResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterOpenAIChatToOpenAIResponses, + From: types.RelayFormatOpenAI, + To: types.RelayFormatOpenAIResponses, + Quality: relayconvert.TextConverterQualityGood, + Req: relayconvert.TextRequestSide{ + Convert: reqConv.ConvertRequest, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + // 流式响应侧:Responses 事件流 → OpenAI chat SSE(方向与请求相反)。 + relayconvert.RegisterStreamConverter( + types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI, + relayconvert.ResponseConverterOAIResponsesToOAIChatStream, + (&oai_responses.ResponsesToOpenAIStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerCrossNativeDirections 注册跨原生方向(Claude↔Gemini、Responses→Claude/Gemini)。 +// +// 响应侧一律为直连转换器(保真度高于两跳,thinking 签名等不经中间格式丢失); +// 请求侧已直连的方向走 Convert,其余走「经 OpenAI 中枢」的步骤链(StepConverters): +// 如 Gemini→Claude 实际执行 Gemini→OpenAI→Claude 两跳,各跳复用已注册的直连转换器, +// 注册时校验 From/To 连续性。 +func registerCrossNativeDirections() { + registerClaudeToGemini() + registerGeminiToClaude() + registerResponsesToClaude() + registerResponsesToGemini() +} + +// registerClaudeToGemini 注册 Claude 入站 → Gemini 上游。 +// 请求直连 Claude→Gemini;响应直连 Gemini→Claude(含流式)。 +func registerClaudeToGemini() { + reqConv := &claude_gemini.ClaudeToGeminiRequestConverter{} + respConv := &claude_gemini.GeminiToClaudeResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterClaudeMessagesToGeminiContent, + From: types.RelayFormatClaude, + To: types.RelayFormatGemini, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + Convert: reqConv.ConvertRequest, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + relayconvert.RegisterStreamConverter( + types.RelayFormatGemini, types.RelayFormatClaude, + relayconvert.ResponseConverterGeminiChatToClaudeMessagesStream, + (&claude_gemini.GeminiToClaudeStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerGeminiToClaude 注册 Gemini 入站 → Claude 上游。 +// 请求链 Gemini→OpenAI→Claude;响应直连 Claude→Gemini(含流式)。 +func registerGeminiToClaude() { + respConv := &claude_gemini.ClaudeToGeminiResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterGeminiContentToClaudeMessages, + From: types.RelayFormatGemini, + To: types.RelayFormatClaude, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + StepConverters: []string{ + relayconvert.ConverterGeminiContentToOpenAIChat, + relayconvert.ConverterOpenAIChatToClaudeMessages, + }, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + relayconvert.RegisterStreamConverter( + types.RelayFormatClaude, types.RelayFormatGemini, + relayconvert.ResponseConverterClaudeMessagesToGeminiChatStream, + (&claude_gemini.ClaudeToGeminiStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerResponsesToClaude 注册 Responses 入站 → Claude 上游。 +// 请求链 Responses→OpenAI→Claude;响应直连 Claude→Responses(含流式)。 +func registerResponsesToClaude() { + respConv := &native_responses.ClaudeToResponsesResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterResponsesToClaudeMessages, + From: types.RelayFormatOpenAIResponses, + To: types.RelayFormatClaude, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + StepConverters: []string{ + relayconvert.ConverterOpenAIResponsesToOpenAIChat, + relayconvert.ConverterOpenAIChatToClaudeMessages, + }, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + relayconvert.RegisterStreamConverter( + types.RelayFormatClaude, types.RelayFormatOpenAIResponses, + relayconvert.ResponseConverterClaudeMessagesToOAIResponsesStream, + (&native_responses.ClaudeToResponsesStreamConverter{}).ConvertStreamResponse, + ) +} + +// registerResponsesToGemini 注册 Responses 入站 → Gemini 上游。 +// 请求链 Responses→OpenAI→Gemini;响应直连 Gemini→Responses(含流式)。 +func registerResponsesToGemini() { + respConv := &native_responses.GeminiToResponsesResponseConverter{} + + relayconvert.RegisterTextConverter(relayconvert.TextConverterSpec{ + ID: relayconvert.ConverterOpenAIResponsesToGemini, + From: types.RelayFormatOpenAIResponses, + To: types.RelayFormatGemini, + Quality: relayconvert.TextConverterQualityFair, + Req: relayconvert.TextRequestSide{ + StepConverters: []string{ + relayconvert.ConverterOpenAIResponsesToOpenAIChat, + relayconvert.ConverterOpenAIChatToGeminiContent, + }, + }, + Resp: relayconvert.TextResponseSide{ + Convert: func(ctx context.Context, info convmeta.Meta, response any) (any, *dto.Usage, error) { + result, err := respConv.ConvertResponse(ctx, info, response) + return result, nil, err + }, + }, + }) + + relayconvert.RegisterStreamConverter( + types.RelayFormatGemini, types.RelayFormatOpenAIResponses, + relayconvert.ResponseConverterGeminiChatToOAIResponsesStream, + (&native_responses.GeminiToResponsesStreamConverter{}).ConvertStreamResponse, + ) +} diff --git a/relaykit/relayconvert/register/register_test.go b/relaykit/relayconvert/register/register_test.go index 8c32d7d..7ddff0a 100644 --- a/relaykit/relayconvert/register/register_test.go +++ b/relaykit/relayconvert/register/register_test.go @@ -27,18 +27,6 @@ func TestBuiltinConvertersRegistered(t *testing.T) { wantReq: true, wantResp: true, }, - { - name: "OpenAI→Coze", - converterID: relayconvert.ConverterOpenAIChatToCoze, - wantReq: true, - wantResp: true, - }, - { - name: "OpenAI→Dify", - converterID: relayconvert.ConverterOpenAIChatToDify, - wantReq: true, - wantResp: true, - }, { name: "OpenAI→Ollama", converterID: relayconvert.ConverterOpenAIChatToOllama, @@ -84,18 +72,6 @@ func TestBuiltinStreamConvertersRegistered(t *testing.T) { to: types.RelayFormatOpenAI, wantID: relayconvert.ResponseConverterGeminiChatToOAIChatStream, }, - { - name: "Coze→OpenAI", - from: types.RelayFormatCoze, - to: types.RelayFormatOpenAI, - wantID: relayconvert.ResponseConverterCozeChatToOAIChatStream, - }, - { - name: "Dify→OpenAI", - from: types.RelayFormatDify, - to: types.RelayFormatOpenAI, - wantID: relayconvert.ResponseConverterDifyChatToOAIChatStream, - }, { name: "Ollama→OpenAI", from: types.RelayFormatOllama, diff --git a/relaykit/relayconvert/register/roundtrip_test.go b/relaykit/relayconvert/register/roundtrip_test.go index f3b4dec..1604cd5 100644 --- a/relaykit/relayconvert/register/roundtrip_test.go +++ b/relaykit/relayconvert/register/roundtrip_test.go @@ -59,19 +59,6 @@ func TestRegisteredConverters_Roundtrip(t *testing.T) { UsageMetadata: &dto.GeminiUsageMetadata{PromptTokenCount: 5, CandidatesTokenCount: 3}, }, }, - { - name: "Coze", converterID: relayconvert.ConverterOpenAIChatToCoze, upstream: "bot-123", - // Coze 上游始终为 SSE,非流式客户端也由桥接缓冲整段后交给转换器解析。 - nativeResp: []byte("event: conversation.message.completed\n" + - `data: {"role":"assistant","type":"answer","content":"hi"}` + "\n"), - }, - { - name: "Dify", converterID: relayconvert.ConverterOpenAIChatToDify, upstream: "dify-bot", - nativeResp: &dto.DifyBlockingResponse{ - Answer: "hi", - Metadata: dto.DifyMeta{Usage: dto.DifyUsage{TotalTokens: 8, PromptTokens: 5, CompletionTokens: 3}}, - }, - }, { name: "Ollama", converterID: relayconvert.ConverterOpenAIChatToOllama, upstream: "llama3", nativeResp: &dto.OllamaChatResponse{ diff --git a/relaykit/relayconvert/request_registry.go b/relaykit/relayconvert/request_registry.go index fef1839..ceadbed 100644 --- a/relaykit/relayconvert/request_registry.go +++ b/relaykit/relayconvert/request_registry.go @@ -10,6 +10,7 @@ package relayconvert import ( "context" "fmt" + "sort" "strings" "sync" @@ -63,14 +64,6 @@ var ( requestConverterDirectRoutes = make(map[requestConverterRoute]string) ) -const ( - requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content" - requestConverterClaudeToResponses = "claude_messages_to_openai_responses" - requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages" - requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses" - requestConverterResponsesToClaude = "openai_responses_to_claude_messages" -) - const ( ConverterNone = "none" ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions" @@ -83,9 +76,14 @@ const ( ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content" // OpenAI → 原生格式供应商(请求侧) - ConverterOpenAIChatToCoze = "openai_chat_completions_to_coze_chat" - ConverterOpenAIChatToDify = "openai_chat_completions_to_dify_chat_messages" ConverterOpenAIChatToOllama = "openai_chat_completions_to_ollama_chat" + + // 跨原生方向(请求侧为「经 OpenAI 中枢」步骤链,响应侧为直连转换器) + ConverterClaudeMessagesToGeminiContent = "claude_messages_to_gemini_generate_content" + ConverterClaudeMessagesToResponses = "claude_messages_to_openai_responses" + ConverterGeminiContentToClaudeMessages = "gemini_generate_content_to_claude_messages" + ConverterGeminiContentToResponses = "gemini_generate_content_to_openai_responses" + ConverterResponsesToClaudeMessages = "openai_responses_to_claude_messages" ) // registerBuiltinRequestConverter 注册一个请求转换器 spec。 @@ -164,6 +162,21 @@ func LookupRequestConverter(converter string) (RequestConverterSpec, bool) { return cloneRequestConverterSpec(spec), true } +// ListRequestConverterIDs 返回全部已注册请求转换器 ID(字典序副本)。 +// 供诊断与测试枚举使用:能力守恒等注册表级测试据此保证「每个已注册方向都有期望条目」, +// 新注册方向若未同步补充测试期望会立即被发现。 +func ListRequestConverterIDs() []string { + requestConverterMu.RLock() + defer requestConverterMu.RUnlock() + + ids := make([]string, 0, len(requestConverters)) + for id := range requestConverters { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) { requestConverterMu.RLock() defer requestConverterMu.RUnlock() diff --git a/relaykit/relayconvert/response_registry.go b/relaykit/relayconvert/response_registry.go index 760ba82..efb77b0 100644 --- a/relaykit/relayconvert/response_registry.go +++ b/relaykit/relayconvert/response_registry.go @@ -10,6 +10,7 @@ package relayconvert import ( "context" "fmt" + "sort" "strings" "sync" @@ -105,19 +106,27 @@ const ( ResponseConverterGeminiChatToOAIChatStream = "gemini_chat_to_oai_chat_stream_resp" // 原生格式供应商 → OpenAI(响应侧,含流式) - ResponseConverterCozeChatToOAIChat = "coze_chat_to_oai_chat_resp" - ResponseConverterCozeChatToOAIChatStream = "coze_chat_to_oai_chat_stream_resp" - ResponseConverterDifyChatToOAIChat = "dify_chat_to_oai_chat_resp" - ResponseConverterDifyChatToOAIChatStream = "dify_chat_to_oai_chat_stream_resp" ResponseConverterOllamaChatToOAIChat = "ollama_chat_to_oai_chat_resp" ResponseConverterOllamaChatToOAIChatStream = "ollama_chat_to_oai_chat_stream_resp" - responseConverterClaudeToGemini = "claude_messages_to_gemini_chat_resp" - responseConverterClaudeToResponses = "claude_messages_to_oai_responses_resp" - responseConverterGeminiToClaude = "gemini_chat_to_claude_messages_resp" - responseConverterGeminiToResponses = "gemini_chat_to_oai_responses_resp" - responseConverterResponsesToClaude = "oai_responses_to_claude_messages_resp" - responseConverterResponsesToGemini = "oai_responses_to_gemini_chat_resp" + // 反向方向(OpenAI 上游 → 非 OpenAI 客户端)与 Responses 双向的流式转换器 ID。 + // 流式注册表按数据方向 (from=上游格式, to=客户端格式) 登记。 + ResponseConverterOAIChatToClaudeMessagesStream = "oai_chat_to_claude_messages_stream_resp" + ResponseConverterOAIChatToGeminiChatStream = "oai_chat_to_gemini_chat_stream_resp" + ResponseConverterOAIResponsesToOAIChatStream = "oai_responses_to_oai_chat_stream_resp" + ResponseConverterOAIChatToOAIResponsesStream = "oai_chat_to_oai_responses_stream_resp" + + // 跨原生方向(上游原生 → 另一原生客户端)的非流式转换器 ID(响应侧元数据)。 + ResponseConverterClaudeMessagesToGeminiChat = "claude_messages_to_gemini_chat_resp" + ResponseConverterGeminiChatToClaudeMessages = "gemini_chat_to_claude_messages_resp" + ResponseConverterClaudeMessagesToOAIResponses = "claude_messages_to_oai_responses_resp" + ResponseConverterGeminiChatToOAIResponses = "gemini_chat_to_oai_responses_resp" + + // 跨原生方向(上游原生 → 另一原生客户端)的流式转换器 ID。 + ResponseConverterClaudeMessagesToGeminiChatStream = "claude_messages_to_gemini_chat_stream_resp" + ResponseConverterGeminiChatToClaudeMessagesStream = "gemini_chat_to_claude_messages_stream_resp" + ResponseConverterGeminiChatToOAIResponsesStream = "gemini_chat_to_oai_responses_stream_resp" + ResponseConverterClaudeMessagesToOAIResponsesStream = "claude_messages_to_oai_responses_stream_resp" ) var ( @@ -127,6 +136,20 @@ var ( responseConverterRoutes = make(map[responseConverterRoute]string) ) +// ListResponseConverterIDs 返回全部已注册响应侧转换器 ID(字典序副本,不含别名)。 +// 供诊断与测试枚举使用,与 ListRequestConverterIDs 配套。 +func ListResponseConverterIDs() []string { + responseConverterMu.RLock() + defer responseConverterMu.RUnlock() + + ids := make([]string, 0, len(responseConverters)) + for id := range responseConverters { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + // registerBuiltinResponseConverter 注册响应侧转换器到注册表。 // init() 通过 registerBuiltinTextConverter() 调用本函数。 // diff --git a/relaykit/relayconvert/stream_event.go b/relaykit/relayconvert/stream_event.go new file mode 100644 index 0000000..731df8e --- /dev/null +++ b/relaykit/relayconvert/stream_event.go @@ -0,0 +1,25 @@ +// Package relayconvert — 通用流式事件封装。 +// +// 早期流式转换器只服务「上游原生 → OpenAI 客户端」方向,chunkWriter 直接收 +// *dto.ChatCompletionStreamResponse。反向与跨原生方向的输出是 Claude 事件流 / +// Gemini SSE / Responses 事件流,帧格式各异(是否带 event 名、结束语义), +// 因此引入 StreamEvent 封装:转换器负责产出「事件名 + 负载」,宿主桥接层负责 +// SSE 帧化与 Usage 提取。 +package relayconvert + +import "github.com/qianfree/team-api/relaykit/dto" + +// StreamEvent 一帧流式输出。 +// +// - Event 非空:写为事件帧(event: X\ndata: {json}\n\n),Claude / Responses 风格; +// - Event 为空:写为纯数据帧(data: {json}\n\n),Gemini / OpenAI 风格; +// - Usage 非空:宿主捕获为本次请求的用量(多次出现取最后一次)。 +// 使用带明细的 UsageWithDetails,缓存读/写 token 明细不丢失(计费按明细扣减缓存价)。 +// +// 输出为 OpenAI chat 格式时转换器仍可直接产出 *dto.ChatCompletionStreamResponse +// (宿主桥接层对两种 chunk 类型均兼容)。 +type StreamEvent struct { + Event string + Data any + Usage *dto.UsageWithDetails +} diff --git a/relaykit/relayconvert/stream_registry.go b/relaykit/relayconvert/stream_registry.go index 65f2124..7f2299d 100644 --- a/relaykit/relayconvert/stream_registry.go +++ b/relaykit/relayconvert/stream_registry.go @@ -16,6 +16,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "sync" @@ -80,3 +81,35 @@ func LookupStreamConverter(from, to types.RelayFormat) (StreamConverterFunc, str } return fn, converterID, true } + +// StreamConverterSpec 一个已注册流式转换方向的元信息(枚举用)。 +// From 是上游流格式,To 是客户端格式(流式方向与请求方向相反)。 +type StreamConverterSpec struct { + ID string + From types.RelayFormat + To types.RelayFormat +} + +// ListStreamConverterSpecs 返回全部已注册流式转换方向(按 ID 升序,结果稳定)。 +// 供注册表级测试套件(流式金样本 / 能力守恒)枚举:新注册方向零成本自动纳入。 +func ListStreamConverterSpecs() []StreamConverterSpec { + streamConverterMu.RLock() + defer streamConverterMu.RUnlock() + + out := make([]StreamConverterSpec, 0, len(streamConverterRoutes)) + for route, id := range streamConverterRoutes { + out = append(out, StreamConverterSpec{ID: id, From: route.from, To: route.to}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// LookupStreamConverterByID 按 converterID 查找流式转换函数。 +// 与 ListStreamConverterSpecs 配套使用;未注册返回 (nil, false)。 +func LookupStreamConverterByID(converterID string) (StreamConverterFunc, bool) { + streamConverterMu.RLock() + defer streamConverterMu.RUnlock() + + fn, ok := streamConverterFuncs[converterID] + return fn, ok +} diff --git a/relaykit/relayconvert/text_converter_registry.go b/relaykit/relayconvert/text_converter_registry.go index c3dae28..e0060bf 100644 --- a/relaykit/relayconvert/text_converter_registry.go +++ b/relaykit/relayconvert/text_converter_registry.go @@ -94,11 +94,12 @@ func registerBuiltinTextConverter(spec TextConverterSpec) { if spec.Quality == "" { panic(fmt.Sprintf("text converter %q must declare quality", spec.ID)) } - if !textRequestSideConfigured(spec.Req) { - panic(fmt.Sprintf("text converter %q must declare request conversion", spec.ID)) - } - if !textResponseSideConfigured(spec.Resp) { - panic(fmt.Sprintf("text converter %q must declare response conversion", spec.ID)) + // 单侧注册:请求侧与响应侧至少配置一侧即可。 + // 反向方向(如 Claude 入站 → OpenAI 上游)请求与响应可分属不同 spec 注册。 + reqConfigured := textRequestSideConfigured(spec.Req) + respConfigured := textResponseSideConfigured(spec.Resp) + if !reqConfigured && !respConfigured { + panic(fmt.Sprintf("text converter %q must declare request or response conversion", spec.ID)) } textConverterMu.Lock() @@ -108,26 +109,30 @@ func registerBuiltinTextConverter(spec TextConverterSpec) { panic(fmt.Sprintf("text converter %q is already registered", spec.ID)) } - registerBuiltinRequestConverter(RequestConverterSpec{ - ID: spec.ID, - From: spec.From, - To: spec.To, - Quality: RequestConverterQuality(spec.Quality), - Convert: spec.Req.Convert, - StepConverters: cloneTextConverterStrings(spec.Req.StepConverters), - }) - registerBuiltinResponseConverter(ResponseConverterSpec{ - ID: spec.ID, - From: spec.From, - To: spec.To, - Quality: ResponseConverterQuality(spec.Quality), - Convert: spec.Resp.Convert, - ConvertStream: spec.Resp.ConvertStream, - NewStreamState: spec.Resp.NewStreamState, - ConvertStreamChunk: spec.Resp.ConvertStreamChunk, - FinalizeStream: spec.Resp.FinalizeStream, - StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters), - }) + if reqConfigured { + registerBuiltinRequestConverter(RequestConverterSpec{ + ID: spec.ID, + From: spec.From, + To: spec.To, + Quality: RequestConverterQuality(spec.Quality), + Convert: spec.Req.Convert, + StepConverters: cloneTextConverterStrings(spec.Req.StepConverters), + }) + } + if respConfigured { + registerBuiltinResponseConverter(ResponseConverterSpec{ + ID: spec.ID, + From: spec.From, + To: spec.To, + Quality: ResponseConverterQuality(spec.Quality), + Convert: spec.Resp.Convert, + ConvertStream: spec.Resp.ConvertStream, + NewStreamState: spec.Resp.NewStreamState, + ConvertStreamChunk: spec.Resp.ConvertStreamChunk, + FinalizeStream: spec.Resp.FinalizeStream, + StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters), + }) + } textConverters[spec.ID] = cloneTextConverterSpec(spec) for _, alias := range spec.Resp.Aliases { diff --git a/relaykit/relayconvert/text_converter_registry_test.go b/relaykit/relayconvert/text_converter_registry_test.go index 0dd40ff..e0428f4 100644 --- a/relaykit/relayconvert/text_converter_registry_test.go +++ b/relaykit/relayconvert/text_converter_registry_test.go @@ -53,6 +53,30 @@ func TestRegisterAndLookupTextConverter(t *testing.T) { } } +// TestRegisterTextConverter_SingleSide 单侧注册:仅请求侧或仅响应侧配置均合法, +// 且只把已配置的一侧写入对应注册表。 +func TestRegisterTextConverter_SingleSide(t *testing.T) { + reqOnly := validTextSpec("text_req_only") + reqOnly.Resp = TextResponseSide{} + RegisterTextConverter(reqOnly) + if _, ok := LookupRequestConverter("text_req_only"); !ok { + t.Error("expected request-only spec to register request side") + } + if _, ok := LookupResponseConverter("text_req_only"); ok { + t.Error("request-only spec must not register response side") + } + + respOnly := validTextSpec("text_resp_only") + respOnly.Req = TextRequestSide{} + RegisterTextConverter(respOnly) + if _, ok := LookupResponseConverter("text_resp_only"); !ok { + t.Error("expected response-only spec to register response side") + } + if _, ok := LookupRequestConverter("text_resp_only"); ok { + t.Error("response-only spec must not register request side") + } +} + func TestRegisterTextConverter_Panics(t *testing.T) { // 空 ID assertPanic(t, func() { @@ -78,15 +102,10 @@ func TestRegisterTextConverter_Panics(t *testing.T) { s.Quality = "" RegisterTextConverter(s) }) - // 缺请求侧(Convert 为 nil 且无 StepConverters) + // 双侧均缺失才 panic(单侧注册合法) assertPanic(t, func() { - s := validTextSpec("text_no_req") + s := validTextSpec("text_no_sides") s.Req = TextRequestSide{} - RegisterTextConverter(s) - }) - // 缺响应侧(全部为 nil) - assertPanic(t, func() { - s := validTextSpec("text_no_resp") s.Resp = TextResponseSide{} RegisterTextConverter(s) }) diff --git a/relaykit/types/relay_format.go b/relaykit/types/relay_format.go index 39cb256..3460f17 100644 --- a/relaykit/types/relay_format.go +++ b/relaykit/types/relay_format.go @@ -19,7 +19,5 @@ const ( RelayFormatMjProxy = "mj_proxy" // 剩余原生格式供应商(非 OpenAI 兼容) - RelayFormatCoze = "coze" // 字节 Coze v3 - RelayFormatDify = "dify" // Dify chat-messages RelayFormatOllama = "ollama" // Ollama /api/chat ) diff --git a/web/admin/src/constants/channel.ts b/web/admin/src/constants/channel.ts index d0aeb6f..d9097c2 100644 --- a/web/admin/src/constants/channel.ts +++ b/web/admin/src/constants/channel.ts @@ -18,8 +18,8 @@ export const providerTypeOptions: { label: string; value: number }[] = [ { label: 'SiliconFlow (硅基流动)', value: 25 }, { label: 'Xunfei (讯飞)', value: 26 }, { label: 'OpenRouter', value: 27 }, { label: 'XInference', value: 28 }, { label: 'MiniMax', value: 29 }, { label: 'Submodel', value: 30 }, - { label: 'Coze (扣子)', value: 32 }, - { label: 'Dify', value: 33 }, { label: 'Jimeng (即梦)', value: 34 }, + // 32 = Coze、33 = Dify:渠道已移除,编号永久保留不复用 + { label: 'Jimeng (即梦)', value: 34 }, { label: 'Codex', value: 35 }, { label: 'New API', value: 41 }, { label: 'Sub2API', value: 42 }, ] diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index c198543..c2e266f 100644 --- a/web/admin/src/views/ai/ChannelDetailPage.vue +++ b/web/admin/src/views/ai/ChannelDetailPage.vue @@ -75,6 +75,7 @@ const editForm = reactive({ status: 'active', is_vip: false, use_proxy: false, + web_search_to_google_search: false, sharing_threshold: null as number | null, preemption_threshold: null as number | null, borrowing_cooldown_seconds: null as number | null, @@ -96,6 +97,7 @@ function openEditModal() { status: detail.value.status || 'active', is_vip: detail.value.is_vip || false, use_proxy: detail.value.use_proxy || false, + web_search_to_google_search: detail.value.web_search_to_google_search || false, sharing_threshold: detail.value.sharing_threshold ?? null, preemption_threshold: detail.value.preemption_threshold ?? null, borrowing_cooldown_seconds: detail.value.borrowing_cooldown_seconds ?? null, @@ -1104,6 +1106,12 @@ function formatHeaders(headers: Record): string { 已启用 - + + + googleSearch + - + + {{ detail.created_at }} {{ detail.updated_at }} {{ detail.remark }} @@ -1522,6 +1530,11 @@ function formatHeaders(headers: Record): string { + + + + + diff --git a/web/admin/src/views/ai/ChannelsPage.vue b/web/admin/src/views/ai/ChannelsPage.vue index 46c7c06..debbe72 100644 --- a/web/admin/src/views/ai/ChannelsPage.vue +++ b/web/admin/src/views/ai/ChannelsPage.vue @@ -264,8 +264,6 @@ const providerDefaultURLs: Record = { 28: 'http://localhost:9997', 29: 'https://api.minimax.chat', 30: '', - 32: 'https://api.coze.cn', - 33: 'https://api.dify.ai', 34: '', 35: 'https://chatgpt.com', }