diff --git a/.gitignore b/.gitignore index 90f944b..c4ff808 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ build/ # IDE .idea/ +# AI +.claude +.codegraph +.workbuddy + # Environment files with sensitive data .env .env.local @@ -41,3 +46,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/CLAUDE.md b/CLAUDE.md index ed730ee..141cb34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,8 +99,6 @@ team-api/ ├── docs/ # 项目文档 │ ├── 开发计划-v2/ # 分周期开发计划(周期一~六) │ └── 协议文档/ # 协议相关文档 -├── new-api/ # 参考项目(只读,不修改) -├── sub2api/ # 参考项目(只读,不修改) ├── main.go # 应用入口 ├── go.mod └── Makefile @@ -223,7 +221,7 @@ cmd(路由注册) ### 多租户隔离 -行级隔离:所有业务表包含 `tenant_id` 字段,GoFrame ORM 中间件全局注入 `WHERE tenant_id = ?`。请求链路通过 Context 传递 tenant_id。 +行级隔离:所有业务表包含 `tenant_id` 字段。**没有 ORM 全局注入/hook 这类安全网**——隔离完全靠 logic 层手写过滤:租户身份一律取 `middleware.GetTenantID(ctx)`(来自 TenantAuth 注入的 ctx,客户端无法伪造),所有涉及租户数据的查询/更新必须显式带 `tenant_id` 条件;按 ID 取对象时用双键校验(`WHERE id = ? AND tenant_id = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。 ### 路由前缀 @@ -271,8 +269,9 @@ cmd(路由注册) 钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)): -- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。 -- **DB 物化**:`bil_wallets.balance / frozen_balance` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。 +- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。 +- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。 +- **DB 物化**:`bil_wallets.balance / frozen_balance / total_consumed` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。 - **预扣记录纯 Redis**:`bil_prededuct_tracks` 表已废弃(预扣明细 = `prededuct:v2:{request_id}` hash + `prededuct_active:{tenant}` 集合,TTL 2h),孤儿清扫由 cron `prededuct_sweep` 每 2 分钟按「重算 frozen = Σ 幸存预扣」自愈,不再依赖 DB 表。 - **可用余额** = balance - frozen_balance,预扣/扣款/退款门槛都在 Redis Lua 内原子判断。 - **幂等闸门**:结算幂等靠 `bil_records.request_id` 唯一约束(DB,先行于任何资金变动);预扣/解冻幂等靠 Redis hash 认领即删。 @@ -474,6 +473,7 @@ cmd(路由注册) | 对比分析 | `docs/new-api-vs-sub2api对比分析报告.md` | 两者差异 | | 大模型实施方案 | `docs/大模型完整实施方案-v2.1.md` | 大模型代理层设计 | | API 格式参考 | `docs/reference/api-format-reference.md` | JSON 示例、SSE 格式、错误映射、中间件差异 | +| 协议转换语料采集 | `docs/协议转换语料采集手册.md` | `corpus-extract` 命令用法:从渠道调试日志提取真实流量语料喂给协议转换测试 | | GoFrame 使用规范 | `docs/reference/goframe-conventions.md` | 框架使用规范 + 已修复的框架 bug 记录 | @@ -491,3 +491,7 @@ cmd(路由注册) | `gf gen pb` | 生成 protobuf 文件 | 需要时 | | `gf run` | 热编译运行开发服务器 | 开发调试 | | `gf build` | 编译生产二进制 | 构建部署 | + + +## AI工具要求 +不允许在提交内容中加入AI工具相关的身份表示和内容,不允许将AI作为仓库贡献者提交 \ No newline at end of file diff --git a/LICENSE b/LICENSE index d0ad6f4..3e75ed5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,11 @@ -Team-API -Copyright (C) 2026 qianfree <406615373@qq.com> +Team-API Community Edition +Copyright (C) 2026 CHENGQIAN WU (qianfree) + +This copy of Team-API is offered under the GNU Affero General Public License +version 3 or any later version. Additional commercial licensing rights, such as +waivers of AGPL obligations or contractual SaaS/OEM/resale rights, are granted +only under a separate written agreement with the copyright holder; this file +does not grant those additional rights. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published diff --git a/README.md b/README.md index d7b87f8..25e0894 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,8 @@ make migrate-status # 查看迁移状态 请遵循项目现有代码风格和 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 提交规范。 +项目当前由 qianfree 单独维护。未来如接受外部贡献,将在合并贡献前提供贡献者许可协议(CLA),以确保 AGPL 与商业授权的权利链清晰完整。 + ## 在线交流 欢迎加QQ群聊天吹水:1095286563 @@ -407,7 +409,7 @@ make migrate-status # 查看迁移状态 ## 许可证 -Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License v3.0](LICENSE) 许可证。 +Copyright (C) 2026 qianfree。本项目社区版采用 [AGPL v3.0 或更高版本](LICENSE) 许可证。 ### 核心要求 @@ -417,7 +419,9 @@ Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License ### 商业授权 -如果你想将本项目代码用于闭源商业产品,AGPL-3.0 许可证不适用。需要单独获取商业授权,请联系:**406615373@qq.com** +如果你希望在不履行 AGPL 开源义务的情况下闭源使用本项目,需要单独获取商业授权,请联系:**406615373@qq.com**。 +商业授权不转让本项目的著作权、专利权、商标权或其他知识产权,具体权利以双方签署的书面协议为准。 + ## 致谢 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..de38c3d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,180 @@ +# 第三方开源组件声明 / Third-Party Notices + +版权所有 © 2026 qianfree。 + +Team-API 使用了以下开源组件,感谢各项目的作者与贡献者。 +本文件用于满足各组件许可证的署名要求,商业授权交付时需随产品附带。 +本文件不改变 Team-API 的主许可证,也不授予任何额外的软件使用、复制、修改、分发、SaaS、OEM 或转售权利;各第三方组件仍受其各自许可证约束。 + +> 本文件由脚本扫描 `go.mod`(Go 模块缓存)与 `web/*/node_modules`(package.json 的 license 字段)生成。 +> 重新生成方式见文末。 + +## 许可证分布概览 + +- **Go 依赖**:以本文件表格为准;发布前应重新扫描并复核许可证,重点关注 GPL/AGPL/LGPL 等 copyleft 许可证 +- **前端依赖**:以本文件表格为准;发布前应重新扫描并复核生产依赖与构建期依赖 + +## Go 依赖(含间接依赖) + +> 下表覆盖生成时已缓存的 122 个模块;另有 66 个模块(均为其他平台专用的间接依赖,如 Windows/macOS 专属包)本次未下载,发布前用文末方法重新扫描补全。 + +| 模块 | 版本 | 许可证 | +|------|------|--------| +| `github.com/aws/aws-sdk-go-v2` | v1.41.6 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` | v1.7.9 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/config` | v1.32.16 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/credentials` | v1.19.15 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` | v1.18.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/configsources` | v1.4.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/endpoints/v2` | v2.7.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/internal/v4a` | v1.4.23 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding` | v1.13.8 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/checksum` | v1.9.14 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/presigned-url` | v1.13.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/internal/s3shared` | v1.19.22 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/s3` | v1.99.1 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/signin` | v1.0.10 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/sso` | v1.30.16 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/ssooidc` | v1.35.20 | Apache-2.0 | +| `github.com/aws/aws-sdk-go-v2/service/sts` | v1.42.0 | Apache-2.0 | +| `github.com/aws/smithy-go` | v1.25.0 | Apache-2.0 | +| `github.com/go-logr/logr` | v1.4.3 | Apache-2.0 | +| `github.com/go-logr/stdr` | v1.2.2 | Apache-2.0 | +| `github.com/pquerna/otp` | v1.5.0 | Apache-2.0 | +| `github.com/richardlehane/mscfb` | v1.0.6 | Apache-2.0 | +| `github.com/richardlehane/msoleps` | v1.0.6 | Apache-2.0 | +| `github.com/sethvargo/go-retry` | v0.3.0 | Apache-2.0 | +| `github.com/tklauser/numcpus` | v0.6.1 | Apache-2.0 | +| `github.com/wenlng/go-captcha-assets` | v1.0.7 | Apache-2.0 | +| `github.com/wenlng/go-captcha/v2` | v2.0.5 | Apache-2.0 | +| `go.opentelemetry.io/auto/sdk` | v1.2.1 | Apache-2.0 | +| `go.opentelemetry.io/otel` | v1.43.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/metric` | v1.43.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/sdk` | v1.39.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/sdk/metric` | v1.39.0 | Apache-2.0 | +| `go.opentelemetry.io/otel/trace` | v1.43.0 | Apache-2.0 | +| `gopkg.in/yaml.v3` | v3.0.1 | Apache-2.0 | +| `github.com/emirpasic/gods/v2` | v2.0.0-alpha | BSD-2-Clause | +| `github.com/gorilla/websocket` | v1.5.3 | BSD-2-Clause | +| `github.com/magiconair/properties` | v1.8.10 | BSD-2-Clause | +| `github.com/pmezard/go-difflib` | v1.0.0 | BSD-2-Clause | +| `github.com/redis/go-redis/v9` | v9.19.0 | BSD-2-Clause | +| `github.com/zeebo/xxh3` | v1.1.0 | BSD-2-Clause | +| `gopkg.in/check.v1` | v1.0.0-20201130134442-10cb98267c6c | BSD-2-Clause | +| `github.com/cloudflare/ahocorasick` | v0.0.0-20240916140611-054963ec9396 | BSD-3-Clause | +| `github.com/fsnotify/fsnotify` | v1.9.0 | BSD-3-Clause | +| `github.com/google/go-cmp` | v0.7.0 | BSD-3-Clause | +| `github.com/google/go-querystring` | v1.0.0 | BSD-3-Clause | +| `github.com/google/uuid` | v1.6.0 | BSD-3-Clause | +| `github.com/grokify/html-strip-tags-go` | v0.1.0 | BSD-3-Clause | +| `github.com/lufia/plan9stats` | v0.0.0-20211012122336-39d0f177ccd0 | BSD-3-Clause | +| `github.com/remyoudompheng/bigfft` | v0.0.0-20230129092748-24d4a6f8daec | BSD-3-Clause | +| `github.com/rogpeppe/go-internal` | v1.14.1 | BSD-3-Clause | +| `github.com/shirou/gopsutil/v3` | v3.24.5 | BSD-3-Clause | +| `github.com/tklauser/go-sysconf` | v0.3.12 | BSD-3-Clause | +| `github.com/xuri/efp` | v0.0.1 | BSD-3-Clause | +| `github.com/xuri/excelize/v2` | v2.10.1 | BSD-3-Clause | +| `github.com/xuri/nfp` | v0.0.2-0.20250530014748-2ddeb826f9a9 | BSD-3-Clause | +| `golang.org/x/crypto` | v0.53.0 | BSD-3-Clause | +| `golang.org/x/image` | v0.25.0 | BSD-3-Clause | +| `golang.org/x/net` | v0.56.0 | BSD-3-Clause | +| `golang.org/x/sync` | v0.22.0 | BSD-3-Clause | +| `golang.org/x/sys` | v0.47.0 | BSD-3-Clause | +| `golang.org/x/text` | v0.40.0 | BSD-3-Clause | +| `golang.org/x/time` | v0.15.0 | BSD-3-Clause | +| `modernc.org/libc` | v1.72.1 | BSD-3-Clause | +| `modernc.org/mathutil` | v1.7.1 | BSD-3-Clause | +| `modernc.org/memory` | v1.11.0 | BSD-3-Clause | +| `modernc.org/sqlite` | v1.49.1 | BSD-3-Clause | +| `github.com/golang/freetype` | v0.0.0-20170609003504-e2365dfdc4a0 | FTL / GPL-2.0-or-later(本项目选择 FTL) | +| `github.com/shoenig/go-m1cpu` | v0.2.1 | MPL-2.0 | +| `github.com/shoenig/test` | v1.7.0 | MPL-2.0 | +| `github.com/davecgh/go-spew` | v1.1.1 | ISC | +| `github.com/BurntSushi/toml` | v1.5.0 | MIT | +| `github.com/Masterminds/semver/v3` | v3.5.0 | MIT | +| `github.com/alicebob/miniredis/v2` | v2.38.0 | MIT | +| `github.com/aliyun/aliyun-oss-go-sdk` | v3.0.2+incompatible | MIT | +| `github.com/boombuler/barcode` | v1.0.1-0.20190219062509-6c824513bacc | MIT | +| `github.com/bsm/ginkgo/v2` | v2.12.0 | MIT | +| `github.com/bsm/gomega` | v1.27.10 | MIT | +| `github.com/cespare/xxhash/v2` | v2.3.0 | MIT | +| `github.com/clbanning/mxj` | v1.8.4 | MIT | +| `github.com/clbanning/mxj/v2` | v2.7.0 | MIT | +| `github.com/dustin/go-humanize` | v1.0.1 | MIT | +| `github.com/fatih/color` | v1.18.0 | MIT | +| `github.com/go-ole/go-ole` | v1.2.6 | MIT | +| `github.com/gogf/gf/contrib/drivers/pgsql/v2` | v2.10.2 | MIT | +| `github.com/gogf/gf/contrib/nosql/redis/v2` | v2.10.2 | MIT | +| `github.com/gogf/gf/v2` | v2.10.2 | MIT | +| `github.com/golang-jwt/jwt/v5` | v5.3.1 | MIT | +| `github.com/klauspost/cpuid/v2` | v2.2.10 | MIT | +| `github.com/kr/pretty` | v0.3.1 | MIT | +| `github.com/kr/text` | v0.2.0 | MIT | +| `github.com/lib/pq` | v1.10.9 | MIT | +| `github.com/mattn/go-colorable` | v0.1.13 | MIT | +| `github.com/mattn/go-isatty` | v0.0.21 | MIT | +| `github.com/mattn/go-runewidth` | v0.0.16 | MIT | +| `github.com/mfridman/interpolate` | v0.0.2 | MIT | +| `github.com/mitchellh/mapstructure` | v1.4.3 | MIT | +| `github.com/mozillazg/go-httpheader` | v0.2.1 | MIT | +| `github.com/ncruces/go-strftime` | v1.0.0 | MIT | +| `github.com/olekukonko/errors` | v1.1.0 | MIT | +| `github.com/olekukonko/ll` | v0.0.9 | MIT | +| `github.com/olekukonko/tablewriter` | v1.1.0 | MIT | +| `github.com/power-devops/perfstat` | v0.0.0-20210106213030-5aafc221ea8c | MIT | +| `github.com/pressly/goose/v3` | v3.27.1 | MIT | +| `github.com/rivo/uniseg` | v0.2.0 | MIT | +| `github.com/robfig/cron/v3` | v3.0.1 | MIT | +| `github.com/samber/lo` | v1.53.0 | MIT | +| `github.com/shopspring/decimal` | v1.4.0 | MIT | +| `github.com/stretchr/testify` | v1.11.1 | MIT | +| `github.com/tencentyun/cos-go-sdk-v5` | v0.7.73 | MIT | +| `github.com/tidwall/gjson` | v1.18.0 | MIT | +| `github.com/tidwall/match` | v1.1.1 | MIT | +| `github.com/tidwall/pretty` | v1.2.0 | MIT | +| `github.com/tidwall/sjson` | v1.2.5 | MIT | +| `github.com/tiendc/go-deepcopy` | v1.7.2 | MIT | +| `github.com/yuin/gopher-lua` | v1.1.1 | MIT | +| `github.com/yusufpapurcu/wmi` | v1.2.4 | MIT | +| `go.uber.org/atomic` | v1.11.0 | MIT | +| `go.uber.org/goleak` | v1.3.0 | MIT | +| `go.uber.org/multierr` | v1.11.0 | MIT | +| `gopkg.in/alexcesaro/quotedprintable.v3` | v3.0.0-20150716171945-2caba252f4dc | MIT | +| `gopkg.in/gomail.v2` | v2.0.0-20160411212932-81ebce5c23df | MIT | +| `github.com/qianfree/team-api/relaykit` | v0.0.0-00010101000000-000000000000 | NO_LICENSE_FILE | + +### Go 依赖特别说明 + +- `github.com/golang/freetype`:双许可证(FTL / GPL-2.0+),本项目**选择 FTL**(类 BSD 带广告条款),不触发 GPL 义务 +- `github.com/shoenig/go-m1cpu`、`github.com/shoenig/test`:MPL-2.0(文件级弱传染),均为间接依赖(前者仅 macOS 平台编译、后者仅测试用),商业分发合规,保留其 LICENSE 即可 +- `github.com/qianfree/team-api/relaykit`:本项目自有子模块,非第三方 + +## 前端依赖(web/admin + web/tenant,含开发依赖) + +- **0BSD**(1 个): `tslib` +- **Apache-2.0**(3 个): `detect-libc`, `echarts`, `typescript` +- **BSD-2-Clause**(1 个): `entities` +- **BSD-3-Clause**(4 个): `highlight.js`, `source-map-js`, `speakingurl`, `zrender` +- **ISC**(2 个): `graceful-fs`, `picocolors` +- **MIT**(89 个): `alien-signals`, `async-validator`, `asynckit`, `axios`, `b-tween`, `b-validate`, `birpc`, `call-bind-apply-helpers`, `chart.js`, `color-convert`, `color-name`, `color-string`, `color`, `combined-stream`, `compute-scroll-into-view`, `copy-anything`, `css-render`, `csstype`, `date-fns-tz`, `date-fns`, `dayjs`, `delayed-stream`, `dunder-proto`, `enhanced-resolve`, `es-define-property`, `es-errors`, `es-object-atoms`, `es-set-tostringtag`, `estree-walker`, `evtd`, `fdir`, `follow-redirects`, `form-data`, `function-bind`, `get-intrinsic`, `get-proto`, `gopd`, `has-symbols`, `has-tostringtag`, `hasown`, `hookable`, `is-arrayish`, `is-what`, `jiti`, `lodash-es`, `lodash`, `magic-string`, `marked`, `marked`, `math-intrinsics`, `mime-db`, `mime-types`, `mitt`, `muggle-string`, `naive-ui`, `nanoid`, `number-precision`, `packrup`, `path-browserify`, `perfect-debounce`, `picomatch`, `pinia`, `postcss`, `proxy-from-env`, `resize-observer-polyfill`, `rfdc`, `rolldown`, `scroll-into-view-if-needed`, `seemly`, `simple-swizzle`, `superjson`, `tailwindcss`, `tapable`, `tinyglobby`, `treemate`, `undici-types`, `unhead`, `vdirs`, `vite`, `vooks`, `vscode-uri`, `vue-chartjs`, `vue-demi`, `vue-echarts`, `vue-router`, `vue-tsc`, `vueuc`, `vue`, `zhead` +- **MPL-2.0**(2 个): `lightningcss-win32-x64-msvc`, `lightningcss` + +### 前端依赖特别说明 + +- `lightningcss`(MPL-2.0):仅构建期 CSS 处理工具,不进入运行时产物 + +--- + +## 重新生成方法 + +```bash +# Go 依赖:列出全部模块并在模块缓存中检查 LICENSE 文件 +go list -m -f "{{if not .Main}}{{.Path}}|{{.Version}}|{{.Dir}}{{end}}" all +# 推荐工具:go install github.com/google/go-licenses@latest +# go-licenses report ./... 可生成带许可证文本链接的完整报告 + +# 前端依赖(在各 web/* 目录下): +# bunx license-checker --summary / npx license-checker --json +``` + +> ⚠️ 本文件列出版本为生成时快照。每次发布商业版本前应重新扫描,重点核对**新增依赖**是否引入 copyleft 类许可证。 diff --git a/api/admin/admin.go b/api/admin/admin.go index 444604c..4fea9c4 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -37,7 +37,9 @@ type IAdminV1 interface { ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) OperationLogExport(ctx context.Context, req *v1.OperationLogExportReq) (res *v1.OperationLogExportRes, err error) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error) + ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error) + AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) AdminLogout(ctx context.Context, req *v1.AdminLogoutReq) (res *v1.AdminLogoutRes, err error) AdminRefresh(ctx context.Context, req *v1.AdminRefreshReq) (res *v1.AdminRefreshRes, err error) AdminSessionList(ctx context.Context, req *v1.AdminSessionListReq) (res *v1.AdminSessionListRes, err error) @@ -50,6 +52,7 @@ type IAdminV1 interface { ChangelogDelete(ctx context.Context, req *v1.ChangelogDeleteReq) (res *v1.ChangelogDeleteRes, err error) ChangelogPublish(ctx context.Context, req *v1.ChangelogPublishReq) (res *v1.ChangelogPublishRes, err error) ChannelList(ctx context.Context, req *v1.ChannelListReq) (res *v1.ChannelListRes, err error) + ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) ChannelCreate(ctx context.Context, req *v1.ChannelCreateReq) (res *v1.ChannelCreateRes, err error) ChannelUpdate(ctx context.Context, req *v1.ChannelUpdateReq) (res *v1.ChannelUpdateRes, err error) ChannelDelete(ctx context.Context, req *v1.ChannelDeleteReq) (res *v1.ChannelDeleteRes, err error) @@ -74,6 +77,7 @@ type IAdminV1 interface { ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error) ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (res *v1.ChannelErrorTopChannelsRes, err error) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) + ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (res *v1.ChannelOAuthAuthURLRes, err error) ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (res *v1.ChannelOAuthExchangeRes, err error) ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (res *v1.ChannelOAuthRefreshRes, err error) @@ -88,6 +92,7 @@ type IAdminV1 interface { AdminDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (res *v1.AdminDashboardRecentAlertsRes, err error) AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error) AdminUsageLogList(ctx context.Context, req *v1.AdminUsageLogListReq) (res *v1.AdminUsageLogListRes, err error) + AdminUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (res *v1.AdminUsageLogDetailRes, err error) AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) AdminBillingRecordList(ctx context.Context, req *v1.AdminBillingRecordListReq) (res *v1.AdminBillingRecordListRes, err error) AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error) @@ -206,7 +211,6 @@ type IAdminV1 interface { PaymentSettingsUpdate(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (res *v1.PaymentSettingsUpdateRes, err error) AdminPermissionList(ctx context.Context, req *v1.AdminPermissionListReq) (res *v1.AdminPermissionListRes, err error) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) - AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) PlanList(ctx context.Context, req *v1.PlanListReq) (res *v1.PlanListRes, err error) PlanCreate(ctx context.Context, req *v1.PlanCreateReq) (res *v1.PlanCreateRes, err error) @@ -234,6 +238,15 @@ type IAdminV1 interface { RedemptionDisable(ctx context.Context, req *v1.RedemptionDisableReq) (res *v1.RedemptionDisableRes, err error) RedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (res *v1.RedemptionUsagesRes, err error) RedemptionExport(ctx context.Context, req *v1.RedemptionExportReq) (res *v1.RedemptionExportRes, err error) + AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) + AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) + AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) + AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) + AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) + AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) + AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) + AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) + AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) Admin2FASetup(ctx context.Context, req *v1.Admin2FASetupReq) (res *v1.Admin2FASetupRes, err error) Admin2FAEnable(ctx context.Context, req *v1.Admin2FAEnableReq) (res *v1.Admin2FAEnableRes, err error) Admin2FADisable(ctx context.Context, req *v1.Admin2FADisableReq) (res *v1.Admin2FADisableRes, err error) @@ -247,6 +260,7 @@ type IAdminV1 interface { AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error) AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error) AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error) + AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error) TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error) diff --git a/api/admin/v1/admin_user.go b/api/admin/v1/admin_user.go index f43a024..5d32fa8 100644 --- a/api/admin/v1/admin_user.go +++ b/api/admin/v1/admin_user.go @@ -30,6 +30,9 @@ type AdminUserItem struct { LastLoginIp string `json:"last_login_ip"` LockedUntil string `json:"locked_until"` CreatedAt string `json:"created_at"` + // Roles 是该账号已分配的角色。role=super_admin 时为空数组(超管权限来自账号属性, + // 不通过角色授予);非超管且为空数组表示「未分配角色」=零权限,界面应醒目提示。 + Roles []AdminRoleBrief `json:"roles"` } // AdminUserCreateReq 创建管理员请求 @@ -39,6 +42,9 @@ type AdminUserCreateReq struct { Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"` Role string `json:"role" d:"admin" v:"in:super_admin,admin#角色只能是 super_admin 或 admin" dc:"角色:super_admin / admin"` + // RoleIDs 在创建时一并分配角色。不传则账号无任何角色(0 权限,只能访问自助接口), + // 这是安全的默认值,但界面应提示「未分配角色」。role=super_admin 时本字段忽略。 + RoleIDs []int64 `json:"role_ids" dc:"分配的角色ID列表"` } type AdminUserCreateRes struct { diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index 2647980..981945f 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -71,6 +71,7 @@ type RequestAuditLogListReq struct { TaskId string `json:"task_id" dc:"异步任务ID(精确匹配)"` Method string `json:"method" dc:"HTTP 方法"` Path string `json:"path" dc:"请求路径"` + Model string `json:"model" dc:"模型名称(精确匹配)"` StatusCode int `json:"status_code" dc:"状态码"` StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` @@ -142,3 +143,13 @@ type ContentFilterLogListRes struct { Page int `json:"page"` PageSize int `json:"page_size"` } + +// ContentFilterLogClearReq 清空全部内容过滤拦截日志(硬删除,用于日志大量堆积时快速释放数据库空间) +type ContentFilterLogClearReq struct { + g.Meta `path:"/audit/content-filter-logs/clear" method:"delete" mime:"json" tags:"管理后台-审计" summary:"清空内容过滤拦截日志(硬删除全部)"` +} + +// ContentFilterLogClearRes 清空内容过滤拦截日志响应 +type ContentFilterLogClearRes struct { + Deleted int64 `json:"deleted" dc:"删除的记录数"` +} diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 6b8e8d6..0cc4bef 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -23,9 +23,32 @@ type AdminLoginRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` } `json:"user"` + // Permissions 是该账号的有效权限点(角色并集 ∪ 特批)。前端据此渲染菜单与按钮; + // 后端鉴权始终以数据库/缓存为准,这里只影响展示。 + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` // 已分配角色(超管为空数组) PendingAgreements []*LoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 } +// AdminMeReq 当前登录用户信息(权限刷新用) +// +// 权限变更后,已登录用户此前需要重新登录才能拿到新权限。前端在应用启动与 token 刷新后 +// 调用本接口刷新,使菜单与按钮跟上最新权限。 +type AdminMeReq struct { + g.Meta `path:"/auth/me" method:"get" mime:"json" tags:"管理后台-认证" summary:"当前登录用户信息与有效权限"` +} + +// AdminMeRes 当前登录用户信息响应 +type AdminMeRes struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` +} + // LoginPendingAgreement 登录时返回的待接受协议信息 type LoginPendingAgreement struct { Id int64 `json:"id"` @@ -74,11 +97,14 @@ type AdminSessionItem struct { UserId int64 `json:"user_id"` Username string `json:"username"` DisplayName string `json:"display_name"` - IpAddress string `json:"ip_address"` - DeviceInfo string `json:"device_info"` - ExpiresAt string `json:"expires_at"` - CreatedAt string `json:"created_at"` - IsCurrent bool `json:"is_current"` + // Role 是会话所属账号的角色(super_admin / admin), + // 前端据此隐藏非本人超管会话的撤销入口(后端另有 assertCanOperateSessions 兜底) + Role string `json:"role"` + IpAddress string `json:"ip_address"` + DeviceInfo string `json:"device_info"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` + IsCurrent bool `json:"is_current"` } // AdminRevokeSessionReq 踢出指定会话请求 diff --git a/api/admin/v1/channel.go b/api/admin/v1/channel.go index dbb1838..c8d677d 100644 --- a/api/admin/v1/channel.go +++ b/api/admin/v1/channel.go @@ -51,6 +51,24 @@ type ChannelItem struct { WorstModelScore float64 `json:"worst_model_score"` } +// ChannelOptionsReq 渠道选项列表请求(下拉选择专用,不分页) +type ChannelOptionsReq struct { + g.Meta `path:"/channels/options" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道选项列表(不分页)"` +} + +// ChannelOptionsRes 渠道选项列表响应 +type ChannelOptionsRes struct { + List []ChannelOptionItem `json:"list"` +} + +// ChannelOptionItem 渠道选项项(精简字段) +type ChannelOptionItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Status string `json:"status"` +} + // ChannelCreateReq 创建渠道请求 type ChannelCreateReq struct { g.Meta `path:"/channels" method:"post" mime:"json" tags:"管理后台-渠道" summary:"创建渠道"` @@ -67,6 +85,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 +114,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 +153,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/api/admin/v1/channel_debug_log.go b/api/admin/v1/channel_debug_log.go index 915e890..06ce149 100644 --- a/api/admin/v1/channel_debug_log.go +++ b/api/admin/v1/channel_debug_log.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // 渠道调试日志:调试开关开启的渠道,per-attempt 记录客户端↔系统↔上游四段完整报文。 // 端点全部为 GET/DELETE,路径在 /channels/ 下,自动命中 rbac 前缀规则 @@ -50,9 +54,14 @@ type ChannelDebugLogDetailReq struct { ID int64 `json:"id" in:"path" v:"required" dc:"记录ID"` } -// ChannelDebugLogDetailRes 渠道调试日志详情响应 +// ChannelDebugLogDetailRes 渠道调试日志详情响应:详情对象平铺进统一响应的 data 字段 +// (json:"-" + 自定义 MarshalJSON),避免统一包装后再多包一层 data type ChannelDebugLogDetailRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *ChannelDebugLogDetailRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } // ChannelDebugLogDeleteReq 删除单条调试日志请求(硬删除) diff --git a/api/admin/v1/channel_error.go b/api/admin/v1/channel_error.go index dce4a73..4ac6d00 100644 --- a/api/admin/v1/channel_error.go +++ b/api/admin/v1/channel_error.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // === 渠道错误监控 === @@ -61,6 +65,22 @@ type ChannelErrorCategoriesReq struct { g.Meta `path:"/monitor/channel-errors/categories" method:"get" mime:"json" tags:"管理后台-监控" summary:"错误分类选项"` } +// ChannelErrorCategoriesRes 分类选项平铺进统一响应的 data 字段(json:"-" + 自定义 +// MarshalJSON),避免统一包装后再多包一层 data type ChannelErrorCategoriesRes struct { - Data []map[string]string `json:"data"` + Data []map[string]string `json:"-"` +} + +func (r *ChannelErrorCategoriesRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) +} + +// ChannelErrorClearReq 清空全部渠道错误事件(硬删除,用于错误大量堆积时快速释放数据库空间) +type ChannelErrorClearReq struct { + g.Meta `path:"/monitor/channel-errors/clear" method:"delete" mime:"json" tags:"管理后台-监控" summary:"清空渠道错误事件(硬删除全部)"` +} + +// ChannelErrorClearRes 清空渠道错误事件响应 +type ChannelErrorClearRes struct { + Deleted int64 `json:"deleted" dc:"删除的记录数"` } diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index 29cb8cb..ca17aed 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -123,14 +125,16 @@ type AdminUsageLogFilter struct { ID int64 `json:"id" dc:"用量记录ID"` TenantID int64 `json:"tenant_id" dc:"租户ID"` UserID int64 `json:"user_id" dc:"用户ID"` - Username string `json:"username" dc:"用户名(模糊匹配)"` ApiKeyID int64 `json:"api_key_id" dc:"API Key ID"` ChannelID int64 `json:"channel_id" dc:"渠道ID"` Model string `json:"model" dc:"模型名称"` Status string `json:"status" dc:"状态"` RequestType int `json:"request_type" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"` - StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` - EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + + UpstreamRequestId string `json:"upstream_request_id" dc:"上游请求ID(精确匹配,排障反查用)"` + + StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` + EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"` } type AdminUsageLogListReq struct { @@ -140,6 +144,54 @@ type AdminUsageLogListReq struct { AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件 } +// AdminUsageLogListItem 用量日志列表项:仅表格列与悬浮明细(Token / 费用分解)所需字段。 +// 纯关联 ID(user_id / project_id / channel_id)与排查大字段(billing_snapshot、 +// user_agent、error_message、request_id 等)不进列表,统一经详情接口按需获取。 +type AdminUsageLogListItem struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + TenantName string `json:"tenant_name"` + Username string `json:"username"` + ProjectName string `json:"project_name"` + ApiKeyId int64 `json:"api_key_id"` + ApiKeyName string `json:"api_key_name"` + ChannelName string `json:"channel_name"` + ModelName string `json:"model_name"` + UpstreamModel string `json:"upstream_model"` + RequestType int `json:"request_type"` + BillingMode string `json:"billing_mode"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheCreation5mTokens int `json:"cache_creation_5m_tokens"` + CacheCreation1hTokens int `json:"cache_creation_1h_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` + AudioInputTokens int `json:"audio_input_tokens"` + AudioOutputTokens int `json:"audio_output_tokens"` + ImageOutputTokens int `json:"image_output_tokens"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + CacheCreationCost float64 `json:"cache_creation_cost"` + CacheReadCost float64 `json:"cache_read_cost"` + TotalCost float64 `json:"total_cost"` + ActualCost float64 `json:"actual_cost"` + RateMultiplier float64 `json:"rate_multiplier"` + LatencyMs int `json:"latency_ms"` + FirstTokenMs int `json:"first_token_ms"` + Status string `json:"status"` + RetryIndex int `json:"retry_index"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type AdminUsageLogListRes struct { + List []*AdminUsageLogListItem `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// AdminUsageLogItem 用量日志全量字段结构体(详情接口专用,列表用上方的精简 ListItem) type AdminUsageLogItem struct { Id int64 `json:"id"` TenantId int64 `json:"tenant_id"` @@ -198,14 +250,25 @@ type AdminUsageLogItem struct { InboundEndpoint string `json:"inbound_endpoint"` RequestId string `json:"request_id"` TaskId string `json:"task_id"` + UpstreamRequestId string `json:"upstream_request_id"` CreatedAt *gtime.Time `json:"created_at"` } -type AdminUsageLogListRes struct { - List []*AdminUsageLogItem `json:"list"` - Total int `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` +// AdminUsageLogDetailReq 用量日志详情(列表只返回表格展示字段, +// billing_snapshot / billing_summary / user_agent / error_message 等大字段经此接口按需获取) +type AdminUsageLogDetailReq struct { + g.Meta `path:"/usage-logs/{id}" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志详情"` + Id int64 `json:"id" in:"path" v:"min:1" dc:"用量记录ID"` +} + +// AdminUsageLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON, +// 与 RequestAuditLogDetailRes 同一模式),避免统一包装后再多包一层 data +type AdminUsageLogDetailRes struct { + Data *AdminUsageLogItem `json:"-"` +} + +func (r *AdminUsageLogDetailRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } // AdminUsageLogSummaryReq 用量日志统计请求(复用筛选条件,不包含分页) diff --git a/api/admin/v1/error_log.go b/api/admin/v1/error_log.go index d88c9e9..d81b7af 100644 --- a/api/admin/v1/error_log.go +++ b/api/admin/v1/error_log.go @@ -58,8 +58,14 @@ type ErrorLogStatsReq struct { g.Meta `path:"/error-logs/stats" method:"get" mime:"json" tags:"管理后台-系统错误" summary:"错误统计"` } +// ErrorLogStatsRes 统计对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON), +// 避免统一包装后再多包一层 data type ErrorLogStatsRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *ErrorLogStatsRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } // ErrorLogClearReq 清空全部错误日志(硬删除,用于错误大量堆积时快速释放数据库空间) diff --git a/api/admin/v1/help_center.go b/api/admin/v1/help_center.go index 8c5695d..76e50fa 100644 --- a/api/admin/v1/help_center.go +++ b/api/admin/v1/help_center.go @@ -11,7 +11,7 @@ type HelpCategoryCreateReq struct { g.Meta `path:"/help-categories" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助分类"` ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"` Name string `json:"name" v:"required|length:1,100" dc:"分类名称"` - Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Description string `json:"description" dc:"分类描述"` SortOrder int `json:"sort_order" d:"0" dc:"排序序号"` Icon string `json:"icon" dc:"图标名称"` @@ -27,7 +27,7 @@ type HelpCategoryUpdateReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"` Name string `json:"name" v:"required|length:1,100" dc:"分类名称"` - Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Description string `json:"description" dc:"分类描述"` SortOrder int `json:"sort_order" d:"0" dc:"排序序号"` Icon string `json:"icon" dc:"图标名称"` @@ -77,7 +77,7 @@ type HelpArticleCreateReq struct { g.Meta `path:"/help-articles" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助文章"` CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"` Title string `json:"title" v:"required|length:1,200" dc:"文章标题"` - Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Content string `json:"content" v:"required" dc:"文章内容(Markdown)"` Summary string `json:"summary" dc:"文章摘要"` Status string `json:"status" d:"draft" v:"in:draft,published" dc:"状态"` @@ -94,7 +94,7 @@ type HelpArticleUpdateReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"` Title string `json:"title" v:"required|length:1,200" dc:"文章标题"` - Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"` + Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"` Content string `json:"content" dc:"文章内容(Markdown),不传则不更新"` Summary string `json:"summary" dc:"文章摘要"` Status string `json:"status" v:"in:draft,published" dc:"状态"` diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 2a945ea..50c349b 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // ModelListReq 模型列表请求 type ModelListReq struct { @@ -8,6 +12,7 @@ type ModelListReq struct { Page int `json:"page" d:"1" v:"min:1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"` + Vendor string `json:"vendor" dc:"研发厂商筛选:openai/anthropic/google/alibaba/deepseek等,空=不过滤"` Status string `json:"status" dc:"状态筛选:active/deprecated/offline"` Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` PricingStatus string `json:"pricing_status" dc:"定价状态筛选:priced/unpriced"` @@ -34,6 +39,7 @@ type ModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) Status string `json:"status"` MaxContext int `json:"max_context_tokens"` MaxOutput int `json:"max_output_tokens"` @@ -45,11 +51,12 @@ type ModelItem struct { DeprecatedAt *string `json:"deprecated_at"` SunsetDate *string `json:"sunset_date"` ReplacementModel string `json:"replacement_model"` - // 定价摘要(来自 mdl_pricing,min_tokens=0 的基准行) - PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered" - InputPrice float64 `json:"input_price"` // $/1M tokens - OutputPrice float64 `json:"output_price"` // $/1M tokens - PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式) + // 定价摘要(来自 mdl_pricing 的 pricing JSONB) + PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered" | "per_second" + InputPrice float64 `json:"input_price"` // $/1M tokens + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 规格→$/秒(仅 per_second 模式) + OutputPrice float64 `json:"output_price"` // $/1M tokens + PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式) // 可用渠道列表(来自 chn_abilities + chn_channels) Channels []ModelChannelInfo `json:"channels"` } @@ -60,6 +67,7 @@ type ModelCreateReq struct { ModelId string `json:"model_id" v:"required|length:1,100#请输入模型标识|模型标识长度1-100" dc:"模型唯一标识"` ModelName string `json:"model_name" dc:"模型显示名称"` Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类必须是 chat/embedding/image/audio/rerank/video" dc:"模型分类"` + Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"` MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"` MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"` Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` @@ -74,10 +82,12 @@ type ModelCreateRes struct { // ModelUpdateReq 更新模型请求 type ModelUpdateReq struct { - g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"` - ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"` - ModelName string `json:"model_name" dc:"模型显示名称"` - Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"` + g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"` + ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"` + ModelName string `json:"model_name" dc:"模型显示名称"` + Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"` + // Vendor 研发厂商;指针语义:nil=不更新,空串=清空回未分类,非空=设置(枚举校验在 logic 层做) + Vendor *string `json:"vendor" dc:"研发厂商"` MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"` MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"` Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` @@ -94,19 +104,20 @@ type ModelDeleteReq struct { ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"` } -// PricingItem 定价项(支持按次/按量/阶梯) +// PricingItem 定价项(支持按次/按量/阶梯/按秒/特殊方案) type PricingItem struct { - BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered" dc:"计费模式"` - MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"` - MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"` - InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"` - OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"` - PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"` - CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"` - CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"` + BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered,per_second,special" dc:"计费模式(special 仅由特殊计费方案编辑器提交,不进通用模式下拉)"` + MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"` + MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"` + InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"` + OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"` + PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"` + CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"` + CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"` + PerSecondPrices map[string]float64 `json:"per_second_prices" dc:"按秒单价矩阵(仅 per_second):分辨率规格→每秒单价(本位币),\"*\"为兜底价,如 {\"480p\":0.25,\"720p\":0.5,\"*\":0.5}"` } -// TimeSegmentItem 时段定价项(mdl_pricing.time_segments JSONB 数组元素)。 +// TimeSegmentItem 时段定价项(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。 // 语义:按数组顺序先命中先生效(促销时段放前面覆盖常驻时段),未命中=默认价(乘数 1.0); // 最终费用 = 各项小计 × 租户乘数 × 时段乘数。 type TimeSegmentItem struct { @@ -119,6 +130,22 @@ type TimeSegmentItem struct { Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:10" dc:"价格乘数,0.5=半价"` } +// ParamConditionItem 参数倍率匹配条件(mdl_pricing.pricing JSONB 顶层 param_multipliers 数组元素)。 +// 匹配对象为网关归一化后的任务体({model, prompt, seconds, metadata{...}}), +// 支持数组通配([*],任一元素命中即命中),字符串数字自动强转参与比较。 +type ParamConditionItem struct { + Path string `json:"path" v:"required" dc:"任务体 JSON 路径,如 metadata.image / seconds / metadata.content[*].video_url"` + Match string `json:"match" v:"required|in:has_value,equals,gt,gte,lt,lte,between" dc:"匹配方式"` + Value any `json:"value,omitempty" dc:"比对值:equals/gt/gte/lt/lte 单值;between 为 [min,max];has_value 不填"` +} + +// ParamMultiplierItem 参数倍率规则:conditions 全部满足(隐式与)时倍率计入连乘;规则间连乘。 +type ParamMultiplierItem struct { + Conditions []ParamConditionItem `json:"conditions" v:"required" dc:"条件列表(全部满足才命中)"` + Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:100" dc:"命中倍率,1.5=加价50%"` + Note string `json:"note" dc:"命中说明(写入计费上下文供对账解释)"` +} + // PricingGetReq 获取模型定价 type PricingGetReq struct { g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"` @@ -126,24 +153,45 @@ type PricingGetReq struct { } type PricingGetRes struct { - List []PricingItem `json:"list"` - TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(锚点行 time_segments)"` - // 以下为锚点行展示字段(仅 min_tokens=0 行生效) + List []PricingItem `json:"list"` + TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(pricing JSONB 顶层)"` + ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(pricing JSONB 顶层,横切所有计费模式)"` + // 官方参考定价(official_pricing JSONB 还原,结构与计费定价完全一致;OfficialItems nil=未配置)。 + // 非计费依据,管理端按官方价 × 折扣快速定价 / 计算现价几折以此为准 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(tiered 展开为多行;nil=未配置)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则"` + // 以下为定价行展示字段 PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"` + // 计费方案:空 = 通用引擎(前端渲染内置通用表单);非空时前端分发到方案专属编辑器 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎)"` + // 方案私有配置(不透明容器):schema 由方案自定义,通用表单不理解其内容 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(随方案编辑器解析)"` } // PricingSetReq 设置模型定价(全量替换) type PricingSetReq struct { - g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"` - ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"` - Items []PricingItem `json:"items" v:"required" dc:"定价列表"` - TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"` - // 以下为锚点行展示字段(全量替换语义:空=清除;price_note 不透出到租户端) + g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"` + ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"` + Items []PricingItem `json:"items" v:"required" dc:"定价列表"` + TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"` + ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选,全量替换;空数组清除)"` + // 官方参考定价(可选,结构与计费定价完全一致,支持全部四种计费模式 + 时段/倍率): + // OfficialItems nil=本次不动库内官方定价;非 nil=全量替换(三个数组全空=清除)。金额本位币,非计费依据 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(nil=不修改;非 nil=全量替换,与另两个官方数组全空=清除)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价(随 official_items 一并全量替换)"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则(随 official_items 一并全量替换)"` + // 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端) PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"` + // 计费方案(全量替换语义:空串=回落通用引擎并清除 scheme_config)。 + // 必须是已注册方案(后端校验),自定义方案建议 custom: 前缀命名 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎并清除私有配置)"` + // 方案私有配置:仅 scheme 非空时允许携带,由方案实现的校验规则把关 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(方案自定义 schema)"` } type PricingSetRes struct{} @@ -179,6 +227,7 @@ type ModelExportReq struct { g.Meta `path:"/models/export" method:"get" mime:"json" tags:"管理后台-模型" summary:"导出模型列表"` Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` Category string `json:"category" in:"query" dc:"模型分类筛选:chat/embedding/image/audio/rerank"` + Vendor string `json:"vendor" in:"query" dc:"研发厂商筛选,空=不过滤"` Status string `json:"status" in:"query" dc:"状态筛选:active/deprecated/offline"` Search string `json:"search" in:"query" dc:"搜索关键词(模型名或显示名)"` } @@ -253,22 +302,28 @@ type ModelImportPreviewRes struct { Models []ModelImportPreviewItem `json:"models"` } -// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记) +// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)。 +// 前端确认导入时把 preview 返回的 models 原样回传,因此该结构必须携带 +// 全量可还原字段(含参数倍率与计费方案声明),缺字段会在回传链路被静默丢弃 type ModelImportPreviewItem struct { - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - Status string `json:"status"` - MaxContextTokens int `json:"max_context_tokens"` - MaxOutputTokens int `json:"max_output_tokens"` - Capabilities map[string]bool `json:"capabilities"` - Description string `json:"description"` - Tags []string `json:"tags"` - SunsetDate string `json:"sunset_date"` - ReplacementModel string `json:"replacement_model"` - Pricing []PricingItem `json:"pricing"` - TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"` - Conflict string `json:"conflict"` // "" 或 "exists" + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Capabilities map[string]bool `json:"capabilities"` + Description string `json:"description"` + Tags []string `json:"tags"` + SunsetDate string `json:"sunset_date"` + ReplacementModel string `json:"replacement_model"` + Pricing []PricingItem `json:"pricing"` + TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"` + ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"` + Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"` + Conflict string `json:"conflict"` // "" 或 "exists" } // ModelImportReq 确认导入模型请求 @@ -277,22 +332,26 @@ type ModelImportReq struct { Models []ModelImportItem `json:"models" v:"required#请至少导入一个模型"` } -// ModelImportItem 导入模型项 +// ModelImportItem 导入模型项(含定价/时段/参数倍率,全量替换语义) type ModelImportItem struct { - ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"` - ModelName string `json:"model_name" dc:"模型显示名称"` - Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"` - Status string `json:"status" dc:"状态"` - MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"` - MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"` - Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` - Description string `json:"description" dc:"模型描述"` - Tags []string `json:"tags" dc:"标签列表"` - SunsetDate string `json:"sunset_date" dc:"下线日期"` - ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"` - Pricing []PricingItem `json:"pricing" dc:"定价列表"` - TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"` - ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"` + ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"` + ModelName string `json:"model_name" dc:"模型显示名称"` + Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"` + Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"` + Status string `json:"status" dc:"状态"` + MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"` + MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"` + Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` + Description string `json:"description" dc:"模型描述"` + Tags []string `json:"tags" dc:"标签列表"` + SunsetDate string `json:"sunset_date" dc:"下线日期"` + ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"` + Pricing []PricingItem `json:"pricing" dc:"定价列表"` + TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"` + ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"` + Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"` + ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"` } // ModelImportRes 导入结果响应 diff --git a/api/admin/v1/monitor.go b/api/admin/v1/monitor.go index 65b9920..584d326 100644 --- a/api/admin/v1/monitor.go +++ b/api/admin/v1/monitor.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // === 监控仪表盘 === @@ -9,8 +13,14 @@ type MonitorDashboardReq struct { Minutes int `json:"minutes" in:"query" d:"5"` } +// MonitorDashboardRes 数据对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON), +// 避免统一包装后再多包一层 data(monitor 包下带 Data 的 Res 均为此模式) type MonitorDashboardRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *MonitorDashboardRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type MonitorTrafficReq struct { @@ -19,7 +29,11 @@ type MonitorTrafficReq struct { } type MonitorTrafficRes struct { - Data any `json:"data"` + Data any `json:"-"` +} + +func (r *MonitorTrafficRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type MonitorTrafficFlowReq struct { @@ -30,7 +44,11 @@ type MonitorTrafficFlowReq struct { } type MonitorTrafficFlowRes struct { - Data any `json:"data"` + Data any `json:"-"` +} + +func (r *MonitorTrafficFlowRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type MonitorModelPerformanceReq struct { @@ -102,7 +120,11 @@ type MonitorLatencyReq struct { } type MonitorLatencyRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *MonitorLatencyRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type MonitorSystemReq struct { @@ -111,7 +133,11 @@ type MonitorSystemReq struct { } type MonitorSystemRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *MonitorSystemRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } // MonitorDispatchReq 渠道调度引擎指标请求 @@ -176,7 +202,11 @@ type MonitorRealtimeReq struct { } type MonitorRealtimeRes struct { - Data any `json:"data"` + Data any `json:"-"` +} + +func (r *MonitorRealtimeRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } // === 告警规则 === @@ -202,7 +232,11 @@ type AlertOptionsReq struct { } type AlertOptionsRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *AlertOptionsRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type AlertRuleCreateReq struct { diff --git a/api/admin/v1/permission.go b/api/admin/v1/permission.go index ca279c6..1211353 100644 --- a/api/admin/v1/permission.go +++ b/api/admin/v1/permission.go @@ -28,19 +28,12 @@ type AdminPermissionUpdateReq struct { type AdminPermissionUpdateRes struct{} -// AdminDataScopeUpdateReq 更新管理员数据范围请求 -type AdminDataScopeUpdateReq struct { - g.Meta `path:"/users/{id}/data-scopes" method:"put" mime:"json" tags:"管理后台-权限管理" summary:"更新管理员数据范围"` - Id int64 `json:"id" in:"path" v:"required" dc:"管理员ID"` - DataScopes []DataScopeInput `json:"data_scopes" dc:"数据范围列表"` -} - -type DataScopeInput struct { - ScopeType string `json:"scope_type" v:"required|in:all,tenant_group,tenant#请选择范围类型|范围类型无效" dc:"范围类型"` - ScopeValue string `json:"scope_value" dc:"范围值"` -} - -type AdminDataScopeUpdateRes struct{} +// 数据范围(sys_admin_data_scopes)的写接口已下线。 +// +// 该表可以配置「全部 / 租户组 / 指定租户」,但全站没有任何查询在读它做过滤 +// (GetDataScopes 零调用方),配出来的范围不会生效。留着一个能存、能显示、 +// 却不约束任何数据的开关,比没有更危险:以为限定了范围的账号实际能看全量数据。 +// 表与存量数据保留,待数据范围真正落到查询链路上再恢复配置入口。 // AdminAllPermissionsReq 获取所有可用权限点请求 type AdminAllPermissionsReq struct { @@ -49,6 +42,25 @@ type AdminAllPermissionsReq struct { type AdminAllPermissionsRes struct { Groups []PermissionGroup `json:"groups"` + // Modules 是「模块 × 档位」的配置元数据,角色配置界面据此渲染 19 行单选, + // 而不是把 60 个权限点摊成复选框。Groups 仍保留,供高级模式按权限点勾选。 + Modules []PermissionModuleMeta `json:"modules"` + // Dangerous 列出需要标红并二次确认的高危权限点(权限点 → 风险说明) + Dangerous map[string]string `json:"dangerous"` +} + +// PermissionModuleMeta 单个模块的档位配置元数据 +type PermissionModuleMeta struct { + Module string `json:"module"` // 模块名,与 PermissionGroup.Name 对应 + Label string `json:"label"` // 中文标签 + Tiers []TierOption `json:"tiers"` // 该模块实际存在差异的档位(等价档位已折叠,界面不渲染无意义选项) +} + +// TierOption 档位选项及其展开后的权限点 +type TierOption struct { + Tier string `json:"tier"` // none / read / operate / full + Label string `json:"label"` // 无 / 只读 / 操作 / 完全 + Permissions []string `json:"permissions"` } type PermissionGroup struct { diff --git a/api/admin/v1/plan.go b/api/admin/v1/plan.go index d9a0fa1..bc0fe00 100644 --- a/api/admin/v1/plan.go +++ b/api/admin/v1/plan.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -59,8 +61,14 @@ type PlanDetailReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` } +// PlanDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON), +// 避免统一包装后再多包一层 data type PlanDetailRes struct { - Data *PlanItem `json:"data"` + Data *PlanItem `json:"-"` +} + +func (r *PlanDetailRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type PlanUpdateReq struct { diff --git a/api/admin/v1/role.go b/api/admin/v1/role.go new file mode 100644 index 0000000..9e85350 --- /dev/null +++ b/api/admin/v1/role.go @@ -0,0 +1,161 @@ +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// ───────────────────────────────────────────────────────────────────────────── +// 角色管理 +// +// 超级管理员不在本接口的管辖范围:它由 sys_admin_users.role 判定并在鉴权时短路, +// 界面上作为一张固定卡片展示「全部权限(不可配置)」。 +// ───────────────────────────────────────────────────────────────────────────── + +// AdminRoleBrief 角色简要信息(登录响应、用户列表等处展示用) +type AdminRoleBrief struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IsEnabled bool `json:"is_enabled"` +} + +// AdminRoleItem 角色列表项 +type AdminRoleItem struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + UserCount int `json:"user_count"` // 关联的管理员账号数(删除前的影响面提示) + PermCount int `json:"perm_count"` // 已授予的权限点数量 + CreatedAt string `json:"created_at"` +} + +// AdminRoleListReq 角色列表请求 +type AdminRoleListReq struct { + g.Meta `path:"/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色列表"` +} + +// AdminRoleListRes 角色列表响应(角色数量少,不分页) +type AdminRoleListRes struct { + List []AdminRoleItem `json:"list"` +} + +// AdminRoleModuleTier 角色在某个模块上的档位选择 +type AdminRoleModuleTier struct { + Module string `json:"module"` // 模块名,如 tenant + Tier string `json:"tier"` // none / read / operate / full;空串表示不匹配任何档位(自定义) +} + +// AdminRoleDetailReq 角色详情请求 +type AdminRoleDetailReq struct { + g.Meta `path:"/roles/{id}" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色详情"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleDetailRes 角色详情响应 +// +// 同时给出权限点全集与档位视图:界面默认按档位渲染(19 行单选), +// 高级模式切到权限点级勾选。二者是同一份数据的两个视图。 +type AdminRoleDetailRes struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + UserCount int `json:"user_count"` + Permissions []string `json:"permissions"` // 权限点全集 + ModuleTiers []AdminRoleModuleTier `json:"module_tiers"` // 按模块归纳的档位视图 + CreatedAt string `json:"created_at"` +} + +// AdminRoleCreateReq 新建角色请求 +type AdminRoleCreateReq struct { + g.Meta `path:"/roles" method:"post" mime:"json" tags:"管理后台-角色" summary:"新建角色"` + Code string `json:"code" v:"required#请输入角色标识" dc:"角色标识(小写字母开头,可含数字与下划线,创建后不可修改)"` + Name string `json:"name" v:"required#请输入角色名称" dc:"角色显示名称"` + Description string `json:"description" dc:"角色说明"` + Sort int `json:"sort" dc:"排序权重"` + Permissions []string `json:"permissions" dc:"权限点列表(与 copy_from_role_id 二选一,同时给出时以本字段为准)"` + CopyFromRoleID int64 `json:"copy_from_role_id" dc:"从现有角色复制权限(建「实习运营」就是复制「运营」再收掉几个模块)"` +} + +// AdminRoleCreateRes 新建角色响应 +type AdminRoleCreateRes struct { + ID int64 `json:"id"` +} + +// AdminRoleUpdateReq 更新角色请求(code 不可修改) +type AdminRoleUpdateReq struct { + g.Meta `path:"/roles/{id}" method:"put" mime:"json" tags:"管理后台-角色" summary:"更新角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` + Name *string `json:"name" dc:"角色显示名称"` + Description *string `json:"description" dc:"角色说明"` + Sort *int `json:"sort" dc:"排序权重"` + Permissions *[]string `json:"permissions" dc:"权限点列表(全量覆盖,传 null 表示不改动权限)"` +} + +// AdminRoleUpdateRes 更新角色响应 +type AdminRoleUpdateRes struct{} + +// AdminRoleDeleteReq 删除角色请求 +type AdminRoleDeleteReq struct { + g.Meta `path:"/roles/{id}" method:"delete" mime:"json" tags:"管理后台-角色" summary:"删除角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleDeleteRes 删除角色响应 +// +// 预置角色同样可删 —— is_builtin 只标识来源,不构成删除保护。 +// 不存在把自己锁死的风险:超级管理员由 sys_admin_users.role 判定,不依赖任何角色记录。 +type AdminRoleDeleteRes struct { + AffectedUsers int `json:"affected_users"` // 因此次删除而失去该角色权限的账号数 +} + +// AdminRoleStatusUpdateReq 启用/禁用角色请求 +type AdminRoleStatusUpdateReq struct { + g.Meta `path:"/roles/{id}/status" method:"put" mime:"json" tags:"管理后台-角色" summary:"启用/禁用角色"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` + IsEnabled bool `json:"is_enabled" dc:"是否启用:禁用后该角色权限对全部关联用户立即失效"` +} + +// AdminRoleStatusUpdateRes 启用/禁用角色响应 +type AdminRoleStatusUpdateRes struct{} + +// AdminRoleResetReq 恢复预置角色的默认权限请求 +type AdminRoleResetReq struct { + g.Meta `path:"/roles/{id}/reset" method:"post" mime:"json" tags:"管理后台-角色" summary:"恢复预置角色默认权限"` + ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"` +} + +// AdminRoleResetRes 恢复默认权限响应 +type AdminRoleResetRes struct { + Permissions []string `json:"permissions"` +} + +// ───────────────────────────────────────────────────────────────────────────── +// 用户角色分配 +// ───────────────────────────────────────────────────────────────────────────── + +// AdminUserRoleListReq 查询管理员已分配角色 +type AdminUserRoleListReq struct { + g.Meta `path:"/users/{id}/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"查询用户角色"` + Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"` +} + +// AdminUserRoleListRes 用户角色列表响应 +type AdminUserRoleListRes struct { + List []AdminRoleBrief `json:"list"` +} + +// AdminUserRoleAssignReq 分配管理员角色请求(全量覆盖) +type AdminUserRoleAssignReq struct { + g.Meta `path:"/users/{id}/roles" method:"put" mime:"json" tags:"管理后台-角色" summary:"分配用户角色"` + Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"` + RoleIDs []int64 `json:"role_ids" dc:"角色ID列表(全量覆盖;传空数组表示不分配任何角色,该账号将只剩自助接口权限)"` +} + +// AdminUserRoleAssignRes 分配用户角色响应 +type AdminUserRoleAssignRes struct{} diff --git a/api/admin/v1/security.go b/api/admin/v1/security.go index f21f538..9b6d647 100644 --- a/api/admin/v1/security.go +++ b/api/admin/v1/security.go @@ -66,6 +66,10 @@ type Admin2FAVerifyRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` } `json:"user"` + // 与 AdminLoginRes 保持一致:2FA 是登录的第二段,同样要下发权限, + // 否则开了两步验证的账号前端权限集为空,菜单全不显示。 + Permissions []string `json:"permissions"` + Roles []AdminRoleBrief `json:"roles"` } // ============================================================ diff --git a/api/admin/v1/support_pending.go b/api/admin/v1/support_pending.go new file mode 100644 index 0000000..7d483b7 --- /dev/null +++ b/api/admin/v1/support_pending.go @@ -0,0 +1,24 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" +) + +// === 顶栏铃铛(客服支持待办聚合) === + +// AdminSupportPendingSummaryReq 顶栏铃铛的待办计数。 +// 口径与工作台刻意不同:工作台只报「超 SLA / 未分配」等异常项避免永久红点; +// 铃铛报全量待处理 —— 管理员处理一条少一条,归零即「没有客户在等回复」, +// 是运营收件箱语义而非消息通知语义(无已读概念,不落消息表)。 +type AdminSupportPendingSummaryReq struct { + g.Meta `path:"/support/pending-summary" method:"get" mime:"json" tags:"管理后台-待办提醒" summary:"顶栏铃铛待办计数"` +} + +type AdminSupportPendingSummaryRes struct { + Tickets int `json:"tickets" dc:"待处理工单数(pending + reopened)"` + TicketsOverdue int `json:"tickets_overdue" dc:"其中超 SLA 未首次响应的工单数"` + Feedbacks int `json:"feedbacks" dc:"待处理反馈数"` + FeedbacksWait string `json:"feedbacks_wait" dc:"最早一条待处理反馈已等待时长(如「3 天」;无待处理时为空)"` + Total int `json:"total" dc:"tickets + feedbacks,铃铛角标总数"` + GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"` +} diff --git a/api/admin/v1/task_management.go b/api/admin/v1/task_management.go index 3e412e3..bb68ed8 100644 --- a/api/admin/v1/task_management.go +++ b/api/admin/v1/task_management.go @@ -19,13 +19,15 @@ type TaskListReq struct { } type ModelTaskItem struct { - ID int64 `json:"id"` - PublicTaskID string `json:"public_task_id"` - Platform string `json:"platform"` - Action string `json:"action"` - Status string `json:"status"` - Progress string `json:"progress"` - ModelName string `json:"model_name"` + ID int64 `json:"id"` + PublicTaskID string `json:"public_task_id"` + Platform string `json:"platform"` + Action string `json:"action"` + Status string `json:"status"` + Progress string `json:"progress"` + ModelName string `json:"model_name"` + // UpstreamModel 上游供应商实际使用的模型名(仅任务详情返回,排障时用于和请求模型对照) + UpstreamModel string `json:"upstream_model,omitempty"` FailReason string `json:"fail_reason,omitempty"` PreDeductAmount float64 `json:"pre_deduct_amount"` ActualCost float64 `json:"actual_cost"` @@ -35,8 +37,16 @@ type ModelTaskItem struct { TenantID int64 `json:"tenant_id"` UserID int64 `json:"user_id"` SubmitTime string `json:"submit_time,omitempty"` - FinishTime string `json:"finish_time,omitempty"` - CreatedAt string `json:"created_at"` + // StartTime 上游开始执行任务的时间(仅任务详情返回;详情时间线用它区分「排队耗时」与「执行耗时」) + StartTime string `json:"start_time,omitempty"` + FinishTime string `json:"finish_time,omitempty"` + CreatedAt string `json:"created_at"` + // RequestID 任务提交时的原始请求 ID(req_xxxxx,仅任务详情返回),关联 aud_request_logs.request_id + RequestID string `json:"request_id,omitempty"` + // 上游排障标识(仅任务详情返回;从 private_data 提取,其余内部字段不外露): + // UpstreamTaskID 任务句柄(上游轮询查询用);UpstreamRequestID 提交调用追踪 ID(部分上游返回) + UpstreamTaskID string `json:"upstream_task_id,omitempty"` + UpstreamRequestID string `json:"upstream_request_id,omitempty"` } type TaskListRes struct { diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 57c40a9..bf9d620 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -5,13 +5,15 @@ import "github.com/gogf/gf/v2/frame/g" // TenantCreateReq 创建租户请求 type TenantCreateReq struct { g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"` - TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"` - TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"` - Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"` - Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"` - Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"管理员密码"` - MaxMembers *int `json:"max_members" dc:"最大成员数(默认10)"` - MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,0不限(默认0)"` + TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"` + TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"` + Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"` + Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"` + Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"管理员密码"` + MaxMembers *int `json:"max_members" dc:"最大成员数(默认10)"` + MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,0不限(默认0)"` + Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符)"` + Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"` } type TenantCreateRes struct { @@ -25,6 +27,8 @@ type TenantListReq struct { PageSize int `json:"page_size" d:"20" dc:"每页数量"` Keyword string `json:"keyword" dc:"搜索关键词(名称/代码)"` Status string `json:"status" dc:"状态筛选:active/suspended/closed"` + Tag string `json:"tag" dc:"标签精确筛选"` + Level *int `json:"level" dc:"等级筛选(对应 tnt_tenant_level_configs.level)"` } type TenantListRes struct { @@ -35,24 +39,27 @@ type TenantListRes struct { } type TenantItem struct { - ID int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - LogoURL string `json:"logo_url"` - OwnerUserID int64 `json:"owner_user_id"` - OwnerName string `json:"owner_name"` - Status string `json:"status"` - MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"` - MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"` - EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"` - EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"` - DefaultChannelScope string `json:"default_channel_scope"` - MemberCount int `json:"member_count"` - WalletBalance string `json:"wallet_balance"` - Level int `json:"level"` - LevelName string `json:"level_name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + LogoURL string `json:"logo_url"` + OwnerUserID int64 `json:"owner_user_id"` + OwnerName string `json:"owner_name"` + Status string `json:"status"` + MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"` + MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"` + EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"` + EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"` + DefaultChannelScope string `json:"default_channel_scope"` + MemberCount int `json:"member_count"` + WalletBalance string `json:"wallet_balance"` + Tags []string `json:"tags" dc:"租户标签(管理后台运营标注)"` + Remark string `json:"remark" dc:"管理员备注"` + TotalConsumed string `json:"total_consumed" dc:"累计消费(本位币,decimal 字符串;Redis 权威计数的物化副本,≤5s 滞后)"` + Level int `json:"level"` + LevelName string `json:"level_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // TenantGetReq 获取租户详情 @@ -85,11 +92,13 @@ type TenantUpdateStatusRes struct{} // TenantUpdateReq 更新租户信息 type TenantUpdateReq struct { g.Meta `path:"/tenants/{id}" method:"put" mime:"json" tags:"管理后台-租户管理" summary:"更新租户"` - Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"` - Name string `json:"name" dc:"租户名称"` - MaxMembers *int `json:"max_members" dc:"最大成员数"` - MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"` - Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"` + Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"` + Name string `json:"name" dc:"租户名称"` + MaxMembers *int `json:"max_members" dc:"最大成员数"` + MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"` + Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"` + Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符,传空数组清空标签)"` + Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"` } type TenantUpdateRes struct{} @@ -138,6 +147,8 @@ type TenantExportReq struct { Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` Keyword string `json:"keyword" in:"query" dc:"搜索关键词(名称/代码)"` Status string `json:"status" in:"query" dc:"状态筛选:active/suspended/closed"` + Tag string `json:"tag" in:"query" dc:"标签筛选"` + Level *int `json:"level" in:"query" dc:"等级筛选"` } type TenantExportRes struct{} diff --git a/api/open/v1/open_api.go b/api/open/v1/open_api.go index 247c02f..425c265 100644 --- a/api/open/v1/open_api.go +++ b/api/open/v1/open_api.go @@ -142,6 +142,8 @@ type OpenKeyItem struct { type OpenKeyCreateReq struct { g.Meta `path:"/v1/keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建API Key"` Name string `json:"name" v:"required|length:2,100#请输入Key名称|名称长度2-100" dc:"Key名称"` + ProjectID int64 `json:"project_id" dc:"关联项目 ID(可选;填写后该 Key 随项目归档一并失效)"` + Scope string `json:"scope" d:"full" v:"in:full,chat_only,embeddings_only,images_only,read_only#权限范围无效" dc:"权限范围"` ModelScopes []string `json:"model_scopes" dc:"可用模型列表(为空则不限)"` QuotaLimit float64 `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"` } diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index a67deac..ded786d 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -28,6 +28,7 @@ type ITenantV1 interface { TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequestAuditLogsReq) (res *v1.TenantRequestAuditLogsRes, err error) TenantRequestAuditLogDetail(ctx context.Context, req *v1.TenantRequestAuditLogDetailReq) (res *v1.TenantRequestAuditLogDetailRes, err error) TenantRegister(ctx context.Context, req *v1.TenantRegisterReq) (res *v1.TenantRegisterRes, err error) + TenantRegisterRateLimit(ctx context.Context, req *v1.TenantRegisterRateLimitReq) (res *v1.TenantRegisterRateLimitRes, err error) TenantLogin(ctx context.Context, req *v1.TenantLoginReq) (res *v1.TenantLoginRes, err error) TenantLogout(ctx context.Context, req *v1.TenantLogoutReq) (res *v1.TenantLogoutRes, err error) TenantRefresh(ctx context.Context, req *v1.TenantRefreshReq) (res *v1.TenantRefreshRes, err error) @@ -35,6 +36,8 @@ type ITenantV1 interface { TenantSessionList(ctx context.Context, req *v1.TenantSessionListReq) (res *v1.TenantSessionListRes, err error) TenantRevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (res *v1.TenantRevokeSessionRes, err error) TenantUsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (res *v1.TenantUsageLogsRes, err error) + TenantUsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (res *v1.TenantUsageLogDetailRes, err error) + TenantUsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (res *v1.TenantUsageLogsSummaryRes, err error) TenantUsageLogsExport(ctx context.Context, req *v1.TenantUsageLogsExportReq) (res *v1.TenantUsageLogsExportRes, err error) TenantRequestClosure(ctx context.Context, req *v1.TenantRequestClosureReq) (res *v1.TenantRequestClosureRes, err error) TenantCancelClosure(ctx context.Context, req *v1.TenantCancelClosureReq) (res *v1.TenantCancelClosureRes, err error) @@ -132,8 +135,6 @@ type ITenantV1 interface { TenantPlanList(ctx context.Context, req *v1.TenantPlanListReq) (res *v1.TenantPlanListRes, err error) TenantPlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (res *v1.TenantPlanCurrentRes, err error) TenantPlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (res *v1.TenantPlanCancelAutoRenewRes, err error) - SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) - SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (res *v1.TenantPluginListRes, err error) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (res *v1.TenantPluginDetailRes, err error) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (res *v1.TenantPluginConfigUpdateRes, err error) diff --git a/api/tenant/v1/audit_tenant.go b/api/tenant/v1/audit_tenant.go index d79440a..945e331 100644 --- a/api/tenant/v1/audit_tenant.go +++ b/api/tenant/v1/audit_tenant.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + "github.com/gogf/gf/v2/frame/g" ) @@ -44,6 +46,7 @@ type TenantRequestAuditLogsReq struct { RequestId string `json:"request_id" in:"query" dc:"Request ID(精确匹配)"` TaskId string `json:"task_id" in:"query" dc:"异步任务ID(精确匹配)"` Path string `json:"path" in:"query" dc:"请求路径(模糊匹配)"` + Model string `json:"model" in:"query" dc:"模型名称(精确匹配)"` StatusCode int `json:"status_code" in:"query" dc:"HTTP 状态码"` StartDate string `json:"start_date" in:"query" dc:"开始日期"` EndDate string `json:"end_date" in:"query" dc:"结束日期"` @@ -61,6 +64,12 @@ type TenantRequestAuditLogDetailReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` } +// TenantRequestAuditLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 +// MarshalJSON),避免统一包装后再多包一层 data(与 admin 端审计详情接口同一模式) type TenantRequestAuditLogDetailRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *TenantRequestAuditLogDetailRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index 0e19822..617eae1 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -34,6 +34,20 @@ type TenantRegisterRes struct { PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 } +// TenantRegisterRateLimitReq 查询当前 IP 的注册限流状态 +type TenantRegisterRateLimitReq struct { + g.Meta `path:"/auth/register-rate-limit" method:"get" mime:"json" tags:"租户控制台-认证" summary:"查询注册限流状态" group:"public" middleware:"-"` +} + +type TenantRegisterRateLimitRes struct { + HourlyLimit int `json:"hourly_limit" dc:"每小时注册上限(0=未启用)"` + HourlyRemaining int `json:"hourly_remaining" dc:"本小时剩余注册次数"` + HourlyResetSeconds int `json:"hourly_reset_seconds" dc:"小时窗口重置剩余秒数(0=无计数记录)"` + DailyLimit int `json:"daily_limit" dc:"每天注册上限(0=未启用)"` + DailyRemaining int `json:"daily_remaining" dc:"今日剩余注册次数"` + DailyResetSeconds int `json:"daily_reset_seconds" dc:"天窗口重置剩余秒数(0=无计数记录)"` +} + // TenantLoginReq 租户登录请求 type TenantLoginReq struct { g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"` diff --git a/api/tenant/v1/billing_tenant.go b/api/tenant/v1/billing_tenant.go index 9e67e20..039ea1b 100644 --- a/api/tenant/v1/billing_tenant.go +++ b/api/tenant/v1/billing_tenant.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // === 租户用量与账单 === @@ -23,6 +27,42 @@ type TenantUsageLogsRes struct { PageSize int `json:"page_size"` } +// TenantUsageLogDetailReq 用量日志详情:列表只返回表格展示字段, +// billing_summary / error_message / user_agent 等大字段经此接口按需获取。 +// 租户隔离:member 角色仅能查看自己的记录。 +type TenantUsageLogDetailReq struct { + g.Meta `path:"/usage-logs/{id}" method:"get" mime:"json" tags:"租户控制台-用量" summary:"用量日志详情"` + Id int64 `json:"id" in:"path" v:"min:1" dc:"用量记录ID"` +} + +// TenantUsageLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 +// MarshalJSON),避免统一包装后再多包一层 data(与 admin 端详情接口同一模式) +type TenantUsageLogDetailRes struct { + Data map[string]any `json:"-"` +} + +func (r *TenantUsageLogDetailRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) +} + +// TenantUsageLogsSummaryReq 用量日志统计请求(复用列表筛选条件,不含分页) +type TenantUsageLogsSummaryReq struct { + g.Meta `path:"/usage-logs/summary" method:"get" mime:"json" tags:"租户控制台-用量" summary:"用量日志统计汇总"` + Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"` + Model string `json:"model" in:"query"` + Status string `json:"status" in:"query"` + RequestType int `json:"request_type" in:"query"` + StartDate string `json:"start_date" in:"query"` + EndDate string `json:"end_date" in:"query"` +} + +// TenantUsageLogsSummaryRes 用量日志统计汇总 +type TenantUsageLogsSummaryRes struct { + TotalCost float64 `json:"total_cost" dc:"总费用(本位币,与列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost)"` + TotalOutputTokens int64 `json:"total_output_tokens" dc:"总输出 token 数"` + CacheReadRatio float64 `json:"cache_read_ratio" dc:"缓存读取占比(%),= cache_read / input_tokens"` +} + // TenantUsageLogsExportReq 导出用量日志请求 type TenantUsageLogsExportReq struct { g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"租户控制台-用量" summary:"导出用量日志"` diff --git a/api/tenant/v1/marketplace.go b/api/tenant/v1/marketplace.go index a03dd74..a9b432a 100644 --- a/api/tenant/v1/marketplace.go +++ b/api/tenant/v1/marketplace.go @@ -8,6 +8,7 @@ type MarketplaceListReq struct { g.Meta `path:"/marketplace/models" method:"get" tags:"公开-模型广场" summary:"获取模型广场列表" group:"public" middleware:"-"` Keyword string `json:"keyword" dc:"搜索关键词"` Category string `json:"category" dc:"模型类别筛选(chat/embedding/image/audio等)"` + Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"` Page int `json:"page" d:"1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` } @@ -17,26 +18,31 @@ type MarketplaceListRes struct { Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` + // Vendors 当前筛选条件(关键词/分类,忽略厂商筛选本身)下有模型的厂商去重列表, + // 供前端只渲染实际有模型的厂商筛选项;顺序不保证,前端按本地字典序展示 + Vendors []string `json:"vendors"` } type MarketplaceModelItem struct { - ModelId string `json:"model_id"` // 模型标识 - ModelName string `json:"model_name"` // 模型显示名称 - Category string `json:"category"` // 分类(chat/embedding/image等) - Description string `json:"description"` // 描述 - MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens - MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens - BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered - InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档) - OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档) - PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request) - CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD) - CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD) - DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示) - PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示) - TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空) - Tags []string `json:"tags"` // 标签 - Capabilities g.Map `json:"capabilities"` // 能力标签 + ModelId string `json:"model_id"` // 模型标识 + ModelName string `json:"model_name"` // 模型显示名称 + Category string `json:"category"` // 分类(chat/embedding/image等) + Vendor string `json:"vendor"` // 研发厂商(openai/anthropic/alibaba等,空=未分类) + Description string `json:"description"` // 描述 + MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens + MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens + BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered/per_second + InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档) + OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档) + PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request) + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 按秒单价矩阵(规格→USD/秒,仅 per_second),"*"为兜底价 + CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD) + CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD) + DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示) + PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示) + TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空) + Tags []string `json:"tags"` // 标签 + Capabilities g.Map `json:"capabilities"` // 能力标签 } type MarketplaceDetailReq struct { diff --git a/api/tenant/v1/models.go b/api/tenant/v1/models.go index 4226778..79d6866 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -6,6 +6,7 @@ import "github.com/gogf/gf/v2/frame/g" type TenantAvailableModelsReq struct { g.Meta `path:"/models" method:"get" mime:"json" tags:"租户控制台-模型" summary:"租户可用模型列表"` Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"` + Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"` Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` // ApiKeyID 按指定 API Key 的模型范围过滤(可选;不传或为 0 时返回租户全部可用模型) ApiKeyID int64 `json:"api_key_id" dc:"API Key ID(可选,传入时按该 Key 的模型范围过滤)"` @@ -36,6 +37,8 @@ type TimePriceItem struct { InputPrice *float64 `json:"input_price"` OutputPrice *float64 `json:"output_price"` PerRequestPrice *float64 `json:"per_request_price"` + // per_second 模型填 PerSecondPrice(兜底档 "*" 的换算价,无 "*" 取矩阵最低档) + PerSecondPrice *float64 `json:"per_second_price"` } // TenantAvailableModelItem 租户可用模型信息 @@ -44,6 +47,7 @@ type TenantAvailableModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) MaxContext int `json:"max_context_tokens"` MaxOutput int `json:"max_output_tokens"` Description string `json:"description"` @@ -58,6 +62,9 @@ type TenantAvailableModelItem struct { CacheReadPrice *float64 `json:"cache_read_price"` CacheCreationPrice *float64 `json:"cache_creation_price"` PricingTiers []PricingTierItem `json:"pricing_tiers"` + // PerSecondPrices 按秒计费矩阵(billing_mode=per_second):分辨率规格 → 每秒单价(本位币), + // "*" 为兜底价。租户倍率/折扣在计费时作用,展示为平台原价。 + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // TimePrices 时段价目(平台配置了时段定价的模型才有;价格为换算后的展示价) TimePrices []TimePriceItem `json:"time_prices"` // DiscountLabel 折扣标签(平台定价锚点行配置的营销展示文案,如"7折起";NULL=不展示) diff --git a/api/tenant/v1/open_platform.go b/api/tenant/v1/open_platform.go index 2462120..a47394d 100644 --- a/api/tenant/v1/open_platform.go +++ b/api/tenant/v1/open_platform.go @@ -21,6 +21,12 @@ type OpenAppListRes struct { PageSize int `json:"page_size"` } +// OpenAppItem 是应用列表项。 +// +// 注:opn_apps.is_sandbox 列仍在,但整个系统没有任何一处读它,创建/更新接口也从未提供过 +// 入参 —— 它恒为 false,是一个只在界面上挂着「沙箱」徽标、实际拿的却是生产权限的假承诺, +// 故不在此暴露。要让沙箱成立,必须先定义其语义(独立数据域?只读?独立配额?)并在 +// CheckOpenPermission / 各 handler 中真正强制,再恢复该字段与配置入口。 type OpenAppItem struct { ID int64 `json:"id"` Name string `json:"name"` @@ -28,7 +34,6 @@ type OpenAppItem struct { AppID string `json:"app_id"` Permissions []string `json:"permissions"` Status string `json:"status"` - IsSandbox bool `json:"is_sandbox"` RateLimit int `json:"rate_limit"` LastUsedAt string `json:"last_used_at,omitempty"` CreatedAt string `json:"created_at"` diff --git a/api/tenant/v1/playground.go b/api/tenant/v1/playground.go deleted file mode 100644 index c133a74..0000000 --- a/api/tenant/v1/playground.go +++ /dev/null @@ -1,40 +0,0 @@ -package v1 - -import "github.com/gogf/gf/v2/frame/g" - -// PlaygroundMessage 对话消息(Playground 和 Sandbox 共用) -type PlaygroundMessage struct { - Role string `json:"role" v:"required|in:system,user,assistant#请指定角色|角色无效"` - Content string `json:"content" v:"required#消息内容不能为空"` -} - -// ============================================================ -// Sandbox(模拟调用,不计费) -// ============================================================ - -// SandboxChatReq Sandbox 对话请求 -type SandboxChatReq struct { - g.Meta `path:"/sandbox/chat" method:"post" mime:"json" tags:"租户控制台-Playground" summary:"Sandbox模拟对话"` - Model string `json:"model" v:"required#请选择模型" dc:"模型名称"` - Messages []PlaygroundMessage `json:"messages" v:"required|length:1,50#请输入消息|消息数量超出限制" dc:"对话消息列表"` - Temperature *float64 `json:"temperature" dc:"温度参数 (0-2)"` - MaxTokens *int `json:"max_tokens" dc:"最大输出 Token 数"` - Stream bool `json:"stream" d:"true" dc:"是否流式响应"` -} - -type SandboxChatRes struct { - Content string `json:"content"` - IsSandbox bool `json:"is_sandbox"` - RemainingQuota int `json:"remaining_quota" dc:"本月剩余沙箱额度"` -} - -// SandboxQuotaReq 沙箱额度查询 -type SandboxQuotaReq struct { - g.Meta `path:"/sandbox/quota" method:"get" tags:"租户控制台-Playground" summary:"查询沙箱额度"` -} - -type SandboxQuotaRes struct { - TotalQuota int `json:"total_quota"` - RemainingQuota int `json:"remaining_quota"` - UsedQuota int `json:"used_quota"` -} diff --git a/api/tenant/v1/project_tenant.go b/api/tenant/v1/project_tenant.go index 355b344..ce1b738 100644 --- a/api/tenant/v1/project_tenant.go +++ b/api/tenant/v1/project_tenant.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -124,8 +126,14 @@ type TenantProjectUsageStatsReq struct { Id int64 `json:"id" in:"path" v:"required|min:1"` } +// TenantProjectUsageStatsRes 统计对象平铺进统一响应的 data 字段(json:"-" + 自定义 +// MarshalJSON),避免统一包装后再多包一层 data type TenantProjectUsageStatsRes struct { - Data map[string]any `json:"data"` + Data map[string]any `json:"-"` +} + +func (r *TenantProjectUsageStatsRes) MarshalJSON() ([]byte, error) { + return json.Marshal(r.Data) } type TenantProjectUsageLogsReq struct { diff --git a/api/tenant/v1/ticket_tenant.go b/api/tenant/v1/ticket_tenant.go index 24fc1eb..3af3e6c 100644 --- a/api/tenant/v1/ticket_tenant.go +++ b/api/tenant/v1/ticket_tenant.go @@ -54,12 +54,13 @@ type TenantTicketGetReq struct { } type TenantTicketReplyItem struct { - Id int64 `json:"id"` - TicketId int64 `json:"ticket_id"` - UserId int64 `json:"user_id"` - UserType string `json:"user_type"` - Content string `json:"content"` - CreatedAt *gtime.Time `json:"created_at"` + Id int64 `json:"id"` + TicketId int64 `json:"ticket_id"` + UserId int64 `json:"user_id"` + UserType string `json:"user_type"` + AuthorName string `json:"author_name"` + Content string `json:"content"` + CreatedAt *gtime.Time `json:"created_at"` } type TenantTicketAttachmentItem struct { diff --git a/docs/audit-db-schema.sql b/docs/audit-db-schema.sql index fc1aa4f..e69ccde 100644 --- a/docs/audit-db-schema.sql +++ b/docs/audit-db-schema.sql @@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS aud_request_logs ( task_status VARCHAR(20), task_result TEXT, task_upstream_headers JSONB, - task_completed_at TIMESTAMPTZ + task_completed_at TIMESTAMPTZ, + model_name VARCHAR(100) ); CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_brin @@ -55,6 +56,11 @@ CREATE INDEX IF NOT EXISTS idx_aud_request_logs_project ON aud_request_logs USING btree (project_id) WHERE project_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_aud_request_logs_task_id ON aud_request_logs (task_id) WHERE task_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_model + ON aud_request_logs (model_name, created_at); +-- created_at 单列 btree(对应迁移 000024):服务无租户条件的全局倒序分页 +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_bt + ON aud_request_logs USING btree (created_at); COMMENT ON TABLE aud_request_logs IS '请求审计日志(记录所有 API 代理请求)'; COMMENT ON COLUMN aud_request_logs.id IS '主键ID'; @@ -87,3 +93,20 @@ COMMENT ON COLUMN aud_request_logs.task_status IS '异步任务终态:SUCCESS COMMENT ON COLUMN aud_request_logs.task_result IS '异步任务完成时上游返回的原始响应体'; COMMENT ON COLUMN aud_request_logs.task_upstream_headers IS '异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)'; COMMENT ON COLUMN aud_request_logs.task_completed_at IS '异步任务达到终态的时间'; +COMMENT ON COLUMN aud_request_logs.model_name IS '请求使用的模型名(用户请求体中的原始模型,未经渠道映射)'; + +-- ============================================================ +-- 增量变更(存量审计库升级用) +-- +-- 本脚本整体幂等(IF NOT EXISTS),存量审计库升级时直接重跑全脚本即可; +-- 也可只执行下方对应版本的增量语句。主库的同等变更由 goose 迁移 +-- migrations/000022、000024 管理,两边需保持同步。 +-- ============================================================ + +-- 000022:补记模型名(存量行无法回填,模型名自升级后开始记录) +ALTER TABLE aud_request_logs ADD COLUMN IF NOT EXISTS model_name VARCHAR(100); +COMMENT ON COLUMN aud_request_logs.model_name IS '请求使用的模型名(用户请求体中的原始模型,未经渠道映射)'; +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_model + ON aud_request_logs (model_name, created_at); +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_bt + ON aud_request_logs USING btree (created_at); diff --git a/docs/reference/api-format-reference.md b/docs/reference/api-format-reference.md index 72e2d4a..28e42d2 100644 --- a/docs/reference/api-format-reference.md +++ b/docs/reference/api-format-reference.md @@ -69,7 +69,7 @@ | 10003 | `CodeChannelUnavailable` | 没有可用的渠道 | | ... | 更多见 `consts.go` | ... | -## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/suno/*`) +## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/v2/*`、`/suno/*`) ### 已注册的代理端点 @@ -92,12 +92,65 @@ | GET | `/v1/realtime` | 实时对话(WebSocket) | OpenAI | WebSocket | | POST | `/v1/video/generations` | 视频生成(异步任务) | 自定义 | 否 | | GET | `/v1/video/generations/{task_id}` | 视频生成任务查询 | — | 否 | +| POST | `/v1/videos` | OpenAI Videos 视频生成(multipart/JSON) | OpenAI | 否 | +| GET | `/v1/videos/{video_id}` | OpenAI Videos 任务查询 | — | 否 | +| GET | `/v1/videos/{video_id}/content` | OpenAI Videos 成品下载 | — | 否 | +| DELETE | `/v1/videos/{video_id}` | OpenAI Videos 任务删除(终态软删) | — | 否 | | GET | `/v1beta/models` | Gemini 模型列表 | Gemini | 否 | | GET | `/v1beta/models/{model}` | Gemini 模型详情 | Gemini | 否 | | POST | `/v1beta/models/{model}` | Gemini 内容生成 | Gemini | SSE | | POST | `/suno/submit/{action}` | Suno 音乐生成提交 | 自定义 | 否 | | POST | `/suno/fetch` | Suno 批量查询 | 自定义 | 否 | | GET | `/suno/fetch/{task_id}` | Suno 任务查询 | — | 否 | +| POST | `/v2/video_generation` | MiniMax H3 视频生成(官方协议 v2) | MiniMax | 否 | +| GET | `/v2/query/video_generation/{task_id}` | MiniMax H3 任务查询(v2) | — | 否 | +| DELETE | `/v2/video_generation/{task_id}` | MiniMax H3 任务取消(仅排队中,v2) | — | 否 | +| POST | `/v1/video_generation` | MiniMax Hailuo 视频生成(官方协议 v1,四形态共用) | MiniMax | 否 | +| GET | `/v1/query/video_generation?task_id=` | MiniMax Hailuo 任务查询(v1,query 参数) | — | 否 | + +### MiniMax 官方视频协议(`/v2/*`,MiniMax-H3/H3-Max) + +对 MiniMax v2 视频协议的原生接入:官方 SDK 把 base_url 指向网关即可直连。协议规格见 `docs/modeldocs/minimax/video/`。 + +**提交** `POST /v2/video_generation`(Bearer 平台 API Key): + +```json +{ + "model": "MiniMax-H3", + "content": [{ "type": "text", "text": "海浪拍打礁石,慢镜头。" }], + "resolution": "768P", + "duration": 6, + "ratio": "16:9" +} +``` + +- `model` / `content`(含非空 text 项)/ `resolution` / `duration` 四项必填;`ratio` 文生视频时必填(不能为 `adaptive`) +- `callback_url` **会被剥离**:网关按自身任务体系轮询计费,上游回调携带的上游 task_id 与网关公开 ID 不互通 +- 提交响应为官方形态:`{"task_id": "task_xxx"}`(网关公开任务 ID,非上游 ID) + +**查询** `GET /v2/query/video_generation/{task_id}` → `{"task": {官方 VideoTask}}`: +状态机 `queued/running/succeeded/failed`(上游取消的任务回放为 `failed`)。`content.url` 为上游限时直链; +`usage/resolution/duration/ratio` 从最近一次上游查询响应回放,任务提交后首拍轮询前(≤15s)可能缺失。 + +**取消** `DELETE /v2/video_generation/{task_id}`:仅支持取消**排队中**(queued)的任务,取消结果以远程返回为准—— +网关调用上游 DELETE,上游确认 `action=cancelled` 才向客户端返回成功 +(`{"task_id": "...", "action": "cancelled", "status": "cancelled"}`);上游拒绝时(如任务已进入 running) +原样透传上游错误。**不支持删除任务记录**(终态任务不可删,软删也不做,任务数据保留用于计费与审计;错误码 `task_not_cancellable`)。 +取消确认后本地状态由轮询循环在下一拍(≤15s)对账收敛为 failed,预扣随之退款。 + +错误响应为 OpenAI 风格 OaiError(`{"error": {"type": "...", "message": "..."}}`),与官方 v2 一致。 + +**OpenAI 协议互通**:同一渠道下 `POST /v1/videos`(OpenAI Videos 协议)与 `POST /v1/video/generations`(通用任务体)可直接调用 MiniMax 视频模型(H3 与 Hailuo 系列按模型自动分派 v2/v1 上游协议)。`size` 只借 OpenAI 协议的字段形态、**值原样透传不换算**——请直接传目标供应商的原生分辨率词汇(H3:`768P`/`2K`/`480P`;Hailuo 2.x:`768P`/`1080P`);非官方档位值会原样送达上游(上游 400 则任务提交失败、预扣全额退款)。时长取 `seconds`/`duration`/`metadata.duration`(缺省 6 秒),`input_reference`/`images[0]` 作为首帧图(v1 转为 `first_frame_image`),纯文生视频时 v2 ratio 默认 `16:9`。 + +### MiniMax 官方视频协议 v1(`/v1/video_generation`,Hailuo 系列) + +Hailuo 2.x 及旧型号(T2V/I2V/S2V-01,传入即转发不拦截)走 v1 扁平字段协议: + +- **提交** `POST /v1/video_generation`:文生(`prompt` 必填)/ 图生(`first_frame_image` 必填,支持 URL 或 data URI)/ 首尾帧(`last_frame_image`,仅 Hailuo-02)/ 主体参考(`subject_reference`,S2V-01)四形态共用;`duration`(默认 6,768P 支持 10)/`resolution`(Hailuo 2.x 默认 `768P`,旧型号默认 `720P`)可选;`prompt_optimizer`/`fast_pretreatment`/`aigc_watermark` 透传;`callback_url` 剥离。响应为官方 `{task_id, base_resp}` 形态(task_id 为网关公开 ID) +- **查询** `GET /v1/query/video_generation?task_id=`:官方 `{task_id, status, file_id, video_width, video_height, base_resp}` 形态,状态机 `Preparing/Queueing/Processing/Success/Fail`;**增补字段** `download_url`(网关轮询时经官方 files/retrieve 二跳解析的限时直链,官方查询响应无此字段)与 `error`(失败原因) +- **成品二跳由网关代取**:官方流程「file_id → GET /v1/files/retrieve → download_url」在轮询时链式完成,`download_url` 落任务记录并回放到查询响应;不暴露 files/retrieve 代理端点 +- **取消/删除**:v1 上游无此能力,端点不提供(v2 的 DELETE 端点对 v1 任务返回 400) +- v1 无 usage 返回,计费按请求规格(per_second 矩阵 × 时长),与 v2 口径一致 ### OpenAI 格式端点响应 diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 7a1498d..4a333f0 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -841,3 +841,138 @@ sender := common.NewEmailSender(&common.EmailConfig{ - 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。 排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。 + +### 2026-09-01:测试里用 `gtest.DataPath()` 当 server 名,Windows 下 `Start()` 静默失败 + +**问题**:`internal/middleware` 的 `request_id_test.go` 三个用例在 Windows 上全部失败,断言形如 `EXPECT 0 == 10`、`EXPECT 0 == 100`,或客户端报 `EOF`——看起来像中间件没生成 RequestId,实则请求根本没发出去。 + +**原因**:测试用路径当服务器名: + +```go +s := g.Server(gtest.DataPath("request-id-test")) // 名字是 D:\...\testdata\request-id-test +s.Start() // 返回值被丢弃 +``` + +GoFrame 用**服务器名**拼 session 存储目录:`%TEMP%\gsessions\`。名字里带盘符冒号时路径非法,`os.MkdirAll` 失败,`Start()` 返回 error——但测试没接这个返回值,于是服务器没监听,`GetListenedPort()` 返回 `-1`,客户端请求 `http://127.0.0.1:-1` 直接 EOF。Linux 下 `gtest.DataPath` 返回的路径不含冒号,同样的代码能跑通,所以问题只在 Windows 暴露。 + +同一文件还继承了 `manifest/config/config.yaml` 的 `server.address: :18888`(真实应用端口),即便名字合法也会与本机运行中的服务抢端口。 + +**修复**:服务器名改用普通标识符,并显式绑定空闲端口: + +```go +s := g.Server("mw-request-id-dual") // 纯名字,不是路径 +s.SetAddr("127.0.0.1:0") // 不继承配置文件端口,让系统分配 +s.SetDumpRouterMap(false) +s.Start() +``` + +**正确做法(通用规则)**: + +- **`g.Server(name)` 的参数是「服务器名」不是「路径」**:它会参与 session 目录等文件路径拼接,只能用普通标识符(字母/数字/连字符)。`gtest.DataPath()` 是用来定位测试数据文件的,不要拿来当名字。 +- **测试里起 server 必须 `SetAddr("127.0.0.1:0")`**:`g.Server()` 会读取项目配置,默认继承生产监听端口,在本机跑着服务时必然冲突。 +- **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。 + +排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。 + +### 2026-09-01(二):channel_proxy_url 双数据源分裂——设置页保存的代理对 relay 渠道转发不生效 + +**问题**:管理后台「系统设置 → 渠道配置 → 代理地址」保存后,渠道编辑中开启「使用代理」的请求并不走该代理。设置页把 `channel_proxy_url` 写入 `sys_options`,但 relay 层(`relay/common/http_client.go` 的 `GetSystemProxyURL`)读的是 `g.Cfg().MustGet(ctx, "channel_proxy_url")`——即 `manifest/config/config.yaml` 配置文件里的**同名 key**。两套数据源互不相通:设置页的值只被 `oauth/client.go`(OAuth 出站)读到,它宣称的主要用途(渠道转发代理、Realtime WebSocket 拨号)从未读到过。 + +**原因**:同一配置 key 被两个模块各自实现了读取逻辑,一个走设置注册表(`sys_options`),一个走 `g.Cfg()`(配置文件),没有收敛到同一加载入口。`g.Cfg().MustGet` 对键不存在不报错、静默返回空串(见 2026-08-31 记录),掩盖了分裂——渠道一直"代理未配置",无人察觉。 + +**修复**: + +1. `relay/common/http_client.go`:`GetSystemProxyURL` 不再读 `g.Cfg()`(含 10s TTL 轮询缓存一并删除),改为读包内 atomic 值,新增 `SetSystemProxyURL` setter; +2. `internal/cmd/cmd.go`:沿用 `SetGlobalRequestTimeoutSeconds` 的桥接注入模式(relay 不反向 import internal,会循环依赖)——启动时 + `OnSettingsChanged("channel_proxy_url")` 时从 `sys_options` 读取并注入,免重启、多实例经 Redis pub/sub 各自刷新; +3. `config.example.yaml` 删除已失效的 `channel_proxy_url` key,配置文件不再承载该配置。 + +**正确做法(通用规则)**: + +- **一个配置 key 只能有一个数据源、一个加载入口**。凡注册进设置注册表(`sys_options`,管理员可改)的 key,所有消费方——包括 relay 这类不 import internal 的独立模块——都必须经同一加载函数取值;独立模块用「setter 注入 + OnSettingsChanged 刷新」桥接(本项目既有模式:全局超时 `SetGlobalRequestTimeoutSeconds`)。 +- **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。 + +排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。 + +### 2026-09-03(三):ghttp 归一化尾部斜杠但不归一化中间的重复斜杠 + +**问题**:管理后台的授权(`AdminPermissionGuard`)按 `r.URL.Path` 做精确路径 / 前缀 / 前缀+后缀三段匹配。若框架对路径的归一化范围与预期不符,同一个 handler 就可能有多种匹配结果——加一个斜杠就绕到另一条(可能更弱的)规则上。 + +**框架实测行为**(GoFrame v2,`ghttp` 路由,实测端口探针验证): + +| 请求路径 | 是否匹配到路由 | handler 里的 `r.URL.Path` | +|----------|----------------|---------------------------| +| `/api/admin/roles` | ✅ | `/api/admin/roles` | +| `/api/admin/roles/` | ✅ | `/api/admin/roles`(**尾部斜杠已被去掉**) | +| `/api/admin/roles//` | ✅ | `/api/admin/roles`(同上) | +| `/api/admin//roles` | ✅ | `/api/admin//roles`(**中间的重复斜杠原样保留**) | +| `/api/admin/./roles`、`/api/x/../admin/roles` | ❌ 404 | — | +| `/api/admin/ROLES` | ❌ 404 | — | + +即:**尾部斜杠会被归一化,路径中间的空段不会**;`.` / `..` 段与大小写变体压根匹配不到路由。 + +**正确做法(通用规则)**: + +- **任何按 `r.URL.Path` 做字符串匹配的中间件(鉴权、限流、审计采样、灰度路由),都不能假设路径已规范化**。中间的 `//` 会带着原样路径进到 handler,字符串规则却按规范形式书写,两者对不上。 +- 处理方式选**拒收**而非**重写**:重写会把原本因规则不匹配而被拒的变体变成放行(等于放宽授权面),拒收既不放宽也不留缝隙。本项目在 `AdminPermissionGuard` 入口用 `isCanonicalPath` 直接 403,正常客户端不会产生这类路径。 +- 尾部斜杠已由框架处理,无需自行 trim,**也不要因此把 `/xxx/` 当作合法输入去写规则**——handler 永远看不到它。 + +排查信号:某接口"多加一个斜杠就能绕过权限/限流";中间件按路径匹配的规则表里出现了同一 handler 的两条规则。 + +### 2026-09-05:`Model.Value()` 不带 Fields 返回结果集第一列(id),不是想要的业务列 + +**问题**:`internal/logic/common/config.go` 的 `SetOption` 中,本位币只读保护用 `dao.SysOptions.Ctx(ctx).Where("key", key).Value()` 读取已存的 `billing_currency` 值,与提交值比对实现"相同值放行"。但 `Model.Value()` 不指定字段时,GoFrame 生成 `SELECT * ... LIMIT 1` 并取**结果集第一列**(`gdb_core_underlying.go` 中 `FirstResultColumn = columnTypes[0].Name()`),对 `sys_options` 来说是 `id`。于是 `old` 永远是行 id(如 `"42"`),与 `"USD"/"CNY"` 永不相等——系统初始化后,设置页保存整个 payment 分类必报"本位币在系统初始化后不可更改",即使用户根本没改本位币。 + +**原因**:`Value()` 的语义是"返回查询结果第一行第一列的值",取哪一列完全由 `Fields()`(或 `Value(fieldsAndWhere...)` 参数)决定;不带字段调用时落到表的第一列(通常是 `id`),且不报错,属于静默取错列。 + +**修复方式**:显式指定列 `dao.SysOptions.Ctx(ctx).Where("key", key).Fields("value").Value()`,与代码库其余 5 处 `Fields("xxx").Value()` 的既有写法保持一致。 + +**正确做法(通用规则)**:`Value()` / `Array()` 这类取单列的快捷方法**必须**显式 `Fields("列名")`,否则取到的是 `id`。评审信号:`grep -rn '\.Value()'` 命中且前面没有 `.Fields(` 的调用。 + +### 2026-09-05:Scan 进无 orm 标签的匿名结构体——TableFields 元数据查询失败时帕斯卡列名裸进 SQL + +**问题**:管理后台租户列表接口 `/api/admin/tenants` 偶发报 `pq: column "Id" does not exist`,SQL 里出现 `"Id","Name","LogoURL"` 等帕斯卡命名列。PostgreSQL 引号标识符大小写敏感,真实列是 `id`/`logo_url`,查询必然失败。 + +**原因**:`internal/logic/admin/tenant.go` 的 `ListTenants` 把查询结果 `Scan` 进一个**只有 json 标签、没有 orm 标签的匿名结构体**。gf 的 `Model.Scan`(`doStructs`)有个内置行为 *"Auto selecting fields by struct attributes"*:模型未显式 `Fields()` 时,会用**扫描结构体的字段名**自动生成 SELECT 字段列表。字段解析链路(`gdb_func.go` 的 `getFieldsFromStructOrMap`):有 orm 标签取标签值,**没有则取 Go 字段名原样**;随后 `mappingAndFilterToTableFields` 尝试用真实表结构把 `Id→id`、`LogoURL→logo_url` 纠偏,但它对元数据查询失败的容忍方式是(`gdb_model_utility.go`): + +```go +fieldsMap, _ := m.TableFields(fieldsTable) // 错误被静默吞掉 +if len(fieldsMap) == 0 { + return fields // 元数据拿不到就原样透传 Go 字段名 +} +``` + +pgsql 驱动的 `TableFields` 要实时查 `pg_attribute` 系统表(`'表名'::regclass`)。远程数据库瞬时抖动一次、这条目录查询失败,帕斯卡字段名就直接进了 SQL。**平时不炸的原因**:TableFields 成功结果进程内永久缓存(`gcache.DurationNoExpire`),启动后第一次查成功就一直复用;失败不缓存,所以重试通常即恢复。同请求内先执行的 `Count()` 走数据链路成功,进一步印证只有目录查询那一步挂了。 + +**修复方式**:给匿名结构体补上 orm 标签(`json:"id" orm:"id"` 等 13 个字段)。orm 标签值直接成为 SELECT 字段名——即使 TableFields 失败、字段名原样透传,透传出去的也是正确的小写蛇形列名,SQL 依然成立,彻底消除对运行时元数据的依赖。随后用 go/AST 脚本全仓排查同款写法,批量修复 24 处(幂等中间件、admin/tenant/open 列表与导出、成员模型范围、预算巡检等)。 + +**不受影响的两种同貌写法(排查时排除)**: + +- **模型链上有显式 `.Fields(...)`**:`doStructs` 的自动投影有前置条件 `len(fields)==0 && len(fieldsEx)==0`,显式 Fields 时不触发,SELECT 列已固定。 +- **`Raw(sql).Scan(&x)` 与 `result[0].Struct(&x)`**:Raw 路径在 `getFormattedSqlAndArgs` 里直接透传 rawSql、不拼接 Fields;`Record.Struct` 是内存行→结构体转换,根本不生成 SQL。 + +**正确做法(通用规则)**: + +- **凡 `Scan`/`Struct`/`Structs` 进非 `model/entity` 生成的结构体(匿名结构体、手写 DTO),每个字段必须带 `orm` 标签**,列名写死不依赖运行时映射;`entity.Xxx` 自带 orm 标签天然安全。 +- 判断风险的方法:`grep -rn 'var .* \[\]struct {' internal/` 找到匿名结构体,再看它是否被传给 `dao.Xxx.Ctx(ctx)...Scan(&x)` 链路且链上无 `Fields(...)`/`Raw(...)`——是则全部依赖这条元数据映射,远程 DB 抖动哪个请求撞上哪个接口炸,且报错完全看不出是网络问题。 + +排查信号:`pq: column "Xxx" does not exist` 且 SQL 列名是帕斯卡命名;偶发、重试即好、与网络抖动时间点吻合。 + +### 2026-09-18:请求体超过 ClientMaxBodySize 时 gf 直接 panic,客户端收到裸 500 "Internal Error" + +**问题**:线上 ZCode 客户端 POST `/v1/messages` 报 500 "Internal Error"(访问日志错误码 `50, "Internal Error"`,错误栈 `Read from request Body failed` → `http: request body too large`),请求耗时 53 秒——客户端慢速上传超大请求体(agent 长上下文 + 内联 base64 媒体),读满上限后被拒。 + +**原因**:三层机制叠加: + +1. gf 在 `ServeHTTP` 里给每个请求体包 `http.MaxBytesReader`,上限取 `server.clientMaxBodySize`,**默认 8MB**(`ghttp_server_config.go`),未配置时即 8MB 生效。 +2. 任何代码调 `r.GetBody()`(内部 `MakeBodyRepeatableRead`)读取超限时,gf **直接 `panic(gerror.WrapCode(gcode.CodeInternalError(50, "Internal Error"), err, "Read from request Body failed"))`**——不是返回错误。 +3. 该 panic 会被 gf `middleware.Next()` 里逐层包裹的 `gutil.TryCatch` **先于自定义 Recovery 中间件**捕获(`ghttp_request_middleware.go` 的 catch 分支 `WriteStatus(500, exception)`),所以项目统一的 JSON 错误格式与 Recovery 兜底全都接不到,客户端拿到框架裸 500 文本。 + +**修复方式**:`manifest/config/config.yaml` 显式配置 `server.clientMaxBodySize: "32MB"`(支持 "32MB"/"512KB" 字符串,gf 用 `gfile.StrToSize` 解析);并新增 `middleware.RelayBodyLimit`(`internal/middleware/body_limit.go`)挂在 relay 路由组 `ApiKeyAuth` 之后、`ContentFilter` 之前:按 `Content-Length` 预检,超限先 `io.Copy(io.Discard, r.Body)` 排空再返回 413 + 协议原生格式错误(`/v1/messages` 前缀走 Claude 格式 `request_too_large`,其余走 OpenAI 格式 `invalid_request_error`)。预检阈值从同一配置键读取,与 `MaxBytesReader` 实际上限天然一致。 + +**正确做法(通用规则)**: + +- **调大上限 ≠ 响应体送达**:拒绝超大请求时必须**先排空请求体再写响应**。客户端上传大 body 往往耗时几十秒,服务端抢先响应并关连接,响应体会在半路被掐掉(实测 body 为空、响应头齐全的竞态);`r.Body` 已被 `MaxBytesReader` 封顶,排空量有界,不会被恶意大包拖死。 +- 无 `Content-Length` 的分块传输无法预检,超限时仍走 gf 的裸 500 兜底(主流 SDK 均携带 Content-Length,属可接受残留)。 +- 自定义 Recovery 中间件**不是**万能兜底:gf 中间件链每层 handler 自带 TryCatch,handler 内的 panic 到不了最外层自定义中间件。 + +排查信号:访问日志出现 `50, "Internal Error"` + `Read from request Body failed` + `http: request body too large` 栈;耗时数万毫秒的 500。 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/协议文档/阿里系列/视频-wan3.0.md b/docs/协议文档/阿里系列/视频-wan3.0.md new file mode 100644 index 0000000..f510c2b --- /dev/null +++ b/docs/协议文档/阿里系列/视频-wan3.0.md @@ -0,0 +1,1119 @@ +> ## Documentation Index +> Fetch the complete documentation index at: https://docs.bailian.console.aliyun.com/llms.txt +> Use this file to discover all available pages before exploring further. + +# 万相3.0-视频生成API参考 + +> 万相3.0是全能参考视频生成模型(All-in-One),统一支持 文生视频 、 图生视频 (首帧/首尾帧)和 参考生视频 等多种用法。最长可生成30秒视频,输出帧率为30fps。 + +使用指南请参见[万相3.0-视频生成](/zh/model-studio/wan3-video-generation-guide)。 + +## 适用范围 + +为确保调用成功,请务必保证模型、Endpoint URL 和 API Key 均属于同一地域。跨地域调用将会失败。 + +- [选择模型](https://bailian.console.aliyun.com/cn-beijing/model/market):前往模型广场选择模型,并确认其所属地域。 +- 选择 URL:选择对应的地域 Endpoint URL。 +- 配置 API Key:选择地域并[获取与配置 API Key](/zh/model-studio/get-api-key),再[配置API Key到环境变量](/zh/model-studio/configure-api-key-through-environment-variables)。 + + + 本文的示例代码适用于北京地域。 + + +## HTTP调用 + +由于视频生成任务耗时较长(通常为1-5分钟),API采用异步调用。整个流程包含 "创建任务 -> 轮询获取" 两个核心步骤,具体如下: + +### 步骤1:创建任务获取任务ID + + + + `POST https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + + `POST https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + + `POST https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + + `POST https://{WorkspaceId}.eu-central-1.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + + `POST https://{WorkspaceId}.us-east-1.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + + `POST https://{WorkspaceId}.cn-hongkong.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` + + + +调用时请将`{WorkspaceId}`替换为真实的[业务空间ID](/zh/model-studio/regions#h2_migrate_domain)。 + + + - 创建成功后,使用接口返回的 `task_id` 查询结果,task\_id 有效期为 24 小时。请勿重复创建任务,轮询获取即可。 + - 新手指引请参见[Postman](/zh/model-studio/first-call-to-image-and-video-api)。 + + + + + + + + + + + + + + + + +
+ #### 请求参数 + + ##### 请求头(Headers) + + Content-Type`string`(必选) + + 请求内容类型。此参数必须设置为`application/json`。 + + Authorization`string`(必选) + + 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 + + X-DashScope-Async`string`(必选) + + 异步处理配置参数。HTTP请求只支持异步,必须设置为`enable`。 + + + 缺少此请求头将报错:“current user api does not support synchronous calls”。 + + + ##### 请求体(Request Body) + + model `string` (必选) + + 模型名称。可选值: + + - `wan3.0-video-prime`:高速版,能力对齐标准版,端到端速度显著提升。 + - `wan3.0-video`:标准版。 + + input `object` (必选) + + 输入的基本信息。`prompt` 和 `media` 必填其一。 + + + prompt `string` (条件必选) + + 文本提示词,用来描述期望生成的视频内容。和 `media` 必填其一。 + + 支持中英文,每个汉字/字母占一个字符,不超过20000个字符,超过部分会自动截断。 + + 在全能参考模式下,prompt中可以用"图1""视频1""音频1"等指代 media 数组中对应顺序的媒体素材。 + + media `array` (条件必选) + + 媒体素材数组,支持图像、视频、音频、文件和网页作为输入。和 `prompt` 必填其一。 + + - 数组中每个元素为一个媒体对象,包含 `type` 与 `url` 字段。 + - 在参考生视频模式下,按照数组顺序定义 `prompt` 中素材引用的顺序。图和视频分别计数,即可同时存在图1、视频1。 + + - 数组中的第 1 个 `reference_video` 对应 视频1,第 2 个对应 视频2,以此类推。 + - 数组中的第 1 个 `reference_image` 对应 图1,第 2 个对应 图2,以此类推。 + - 数组中的第 1 个 `reference_audio` 对应 音频1,第 2 个对应 音频2,以此类推。 + + + type `string` (必选) + + 媒体素材类型。可选值为: + + - `first_frame`:首帧图像。最多1张,严格作为视频第一帧。 + - `last_frame`:尾帧图像。最多1张,严格作为视频最后一帧。 + - `reference_image`:参考图像。最多10张。 + - `reference_video`:参考视频。最多5段,总时长不大于15秒。 + - `reference_audio`:参考音频。最多5段,总时长不大于15秒。 + - `file`:文件。最多1个,不可与 link 同时输入。 + - `link`:网页链接。最多1个,不可与 file 同时输入。 + + + 不同 `type` 的组合规则和限制请参见[素材组合](#w30media_combo_t)。 + + + url `string` (必选) + + 媒体素材URL或Base64 编码数据。 + + + 图像URL或Base64 编码数据。 + + 图像限制: + + - 格式:JPEG、JPG、PNG(不支持透明通道)、BMP、WEBP。 + - 分辨率:单边\[240, 8000]像素。 + - 长宽比:不超过8:1。 + - 文件大小:不超过20MB。 + + 支持输入的格式: + + 1. 公网URL: + + - 支持HTTP或HTTPS协议。 + - 示例值:[https://xxx/xxx.png](https://xxx/xxx.png)。 + 2. 临时URL: + + - 支持OSS协议,必须通过[上传文件获取临时 URL](/zh/model-studio/get-temporary-file-url)。 + - 示例值:oss\://dashscope-instant/xxx/xxx.png。 + 3. Base64 编码图像后的字符串: + + - 数据格式:`data:{MIME_type};base64,{base64_data}`。 + - 示例值:data:image/png;base64,GDU7MtCZzEbTbmRZ......。(编码字符串过长,仅展示片段) + - 详情请参见[传入图像](/zh/model-studio/image-to-video-guide#32d9db99f1fk0)。 + + + + 参考视频URL。 + + 视频限制: + + - 格式:mp4、mov。 + - 时长:单个\[1, 15]秒,总时长不大于15秒。 + - 帧率:≥16 fps。 + - 分辨率:单边\[240, 4096]像素。 + - 长宽比:不超过8:1。 + - 单文件大小:不超过100MB。 + + 支持输入的格式: + + 1. 公网URL: + + - 支持HTTP或HTTPS协议。 + - 示例值:[https://xxx/xxx.mp4](https://xxx/xxx.mp4)。 + 2. 临时URL: + + - 支持OSS协议,必须通过[上传文件获取临时 URL](/zh/model-studio/get-temporary-file-url)。 + - 示例值:oss\://dashscope-instant/xxx/xxx.mp4。 + + + + 参考音频URL。 + + 音频限制: + + - 格式:wav、mp3。 + - 时长:单个\[1, 15]秒,总时长不大于15秒。 + - 文件大小:不超过15MB。 + + 支持输入的格式: + + 1. 公网URL: + + - 支持HTTP或HTTPS协议。 + - 示例值:[https://xxx/xxx.mp3](https://xxx/xxx.mp3)。 + 2. 临时URL: + + - 支持OSS协议,必须通过[上传文件获取临时 URL](/zh/model-studio/get-temporary-file-url)。 + - 示例值:oss\://dashscope-instant/xxx/xxx.mp3。 + + + + 文件URL。 + + 文件限制: + + - 格式:docx、doc、xlsx、xls、pptx、ppt、pdf、txt、key、pages、numbers、md。 + - 文件大小:不超过100MB。 + - 页数限制:不超过50页(对pdf、docx、doc、pptx、ppt、key、pages格式校验)。 + + 支持输入的格式: + + 1. 公网URL: + + - 支持HTTP或HTTPS协议。 + - 示例值:[https://xxx/xxx.pdf](https://xxx/xxx.pdf)。 + 2. 临时URL: + + - 支持OSS协议,必须通过[上传文件获取临时 URL](/zh/model-studio/get-temporary-file-url)。 + - 示例值:oss\://dashscope-instant/xxx/xxx.pdf。 + + + + 公开网页的URL地址。仅支持解析无需登录的公开网页(如新闻、博客、公众号等)。 + + 支持输入的格式: + + 1. 公网URL: + + - 支持HTTP或HTTPS协议。 + - 示例值:[https://xxx/article/xxx](https://xxx/article/xxx)。 + + + + + parameters `object` (可选) + + 视频处理参数。 + + + resolution `string` (可选) + + 生成视频的分辨率档位。默认值为 `1080P`。可选值: + + - `1080P` + - `720P` + - `480P` + + ratio `string` (可选) + + 生成视频的宽高比。可选值: + + - `adaptive`(默认值):自适应长宽比,根据输入媒体比例和意图自动推荐合适的长宽比。 + - `21:9` + - `16:9` + - `4:3` + - `1:1` + - `3:4` + - `9:16` + + duration `integer` (可选) + + 生成视频的时长,单位为秒。默认值为5。 + + - 无视频输入时:取值范围为\[2, 30]的整数。 + - 有视频输入时:输入视频总时长 + 输出视频时长不超过30秒。 + - 传 `-1` 时:智能时长模式,模型根据输入的 prompt、内容和富媒体自动推荐合适时长生成。 + + audio `boolean` (可选) + + 输出视频是否包含音频。 + + - `true`:默认值,输出视频包含声音。 + - `false`:输出视频不包含音轨。 + + 开关声音价格相同。 + + seed `integer` (可选) + + 随机种子,用于复现生成结果。取值范围:-1或\[0, 2147483647]。传入-1或未指定时,系统自动生成随机种子。即使使用相同seed,也不能保证每次生成结果完全一致。 + + prompt\_extend `boolean` (可选) + + 是否开启prompt智能改写。开启后使用大模型对输入prompt进行智能改写。对于较短的prompt生成效果提升明显,但会增加耗时。 + + - `true`:默认值,开启智能改写。 + - `false`:不开启智能改写。 + + + 当传入文档(`file`)或网页(`link`)素材时,`prompt_extend` 取值必须为 `true`。 + + + watermark `boolean` (可选) + + 是否添加水印标识。 + + - `false`:默认值,不添加水印。 + - `true`:添加水印。 + + + + + 通过 `file` 类型传入文件,模型自动理解文件内容生成视频。 + + ```bash + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "一支高端智能眼镜产品广告,整体风格极简、未来感、时尚高级,光影克制,画面以黑色、银灰色、冰蓝色为主色调,局部点缀柔和白光与参数UI图形。开场在纯黑背景中,一副智能眼镜从黑暗中缓缓浮现,镜腿边缘掠过精致高光,镜框轮廓在冷冽边缘光下被勾勒出来,镜头超近距离掠过镜片、鼻托、转轴、镜腿与材质细节,展现金属与高性能复合材料的细腻质感,表面处理高级克制,线条轻薄流畅。随后产品在空中缓慢旋转,画面以极简动态图形同步展示核心参数信息。随后镜头快速收拢,所有零件精准回归组装成完整产品,切换到年轻模特佩戴展示,模特五官立体、气质自信,穿着简洁高级的都市时尚服装,在极简空间和城市光影环境中自然转头、抬手、行走、微笑,镜头从正面、侧面、斜后方展示眼镜佩戴状态,突出轻薄贴合、时尚轮廓与日常百搭属性。结尾在纯色背景中,产品悬浮定格,镜头缓慢推进到品牌logo和核心slogan,整体音乐极简电子氛围配合精准鼓点,节奏干净有力,画面质感高级、克制、纯粹,具有强烈品牌记忆点和国际化科技审美。", + "media": [ + { + "type": "file", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260806/ebapmr/glass.pptx" + } + ] + }, + "parameters": { + "resolution": "480P", + "ratio": "adaptive", + "duration": 10, + "prompt_extend": true + } + }' + ``` + + + + 通过 `input.media` 传入参考图片、视频、音频、文件或网页链接,模型自动理解意图生成视频。 + + ```bash expandable + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "视频1抱着图3,在图4的椅子上弹奏一支舒缓的乡村民谣,并说道:"今天的阳光真好。"图1手中拿着图2,路过视频1,把手中的图2放到视频1旁边的桌子上,并说道:"真好听,能不能再唱一遍"。", + "media": [ + { + "type": "reference_image", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260408/sjuytr/wan-r2v-object-girl.jpg" + }, + { + "type": "reference_video", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/qigswt/wan-r2v-role2.mp4" + }, + { + "type": "reference_image", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/rtjeqf/wan-r2v-object3.png" + }, + { + "type": "reference_image", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/qpzxps/wan-r2v-object4.png" + }, + { + "type": "reference_image", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/wfjikw/wan-r2v-backgroud5.png" + } + ] + }, + "parameters": { + "resolution": "480P", + "ratio": "adaptive", + "duration": 5, + "prompt_extend": true + } + }' + ``` + + + + 仅通过 `prompt` 生成视频,不传入任何媒体文件。 + + ```bash + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "一只小猫在月光下的屋顶上奔跑,城市的霓虹灯在远处闪烁,电影级画质,流畅运镜。" + }, + "parameters": { + "resolution": "480P", + "ratio": "adaptive", + "duration": 5, + "prompt_extend": true + } + }' + ``` + + + + 仅传入 `first_frame`,严格指定视频首帧图像。 + + ```bash + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "一只猫在草地上奔跑", + "media": [ + { + "type": "first_frame", + "url": "https://cdn.translate.alibaba.com/r/wanx-demo-1.png" + } + ] + }, + "parameters": { + "resolution": "480P", + "ratio": "adaptive", + "duration": 5, + "prompt_extend": true + } + }' + ``` + + + + 仅支持传入 `first_frame` 和 `last_frame`,严格指定视频的首帧和尾帧图像。 + + ```bash expandable + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "清晨太阳刚刚升起,在南瓜地里面,有一颗小南瓜上面挂着露珠,突然小南瓜咔擦一声,出现了裂缝,从裂缝中透出金光,小南瓜伴随着金光裂开,出现一团白雾,一只小兔子在南瓜裂开的南瓜中央出现。", + "media": [ + { + "type": "first_frame", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260414/welyei/wan2.7-i2v-first-frame.webp" + }, + { + "type": "last_frame", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260414/zongha/wan2.7-i2v-last-frame.webp" + } + ] + }, + "parameters": { + "resolution": "480P", + "ratio": "adaptive", + "duration": 5, + "prompt_extend": true + } + }' + ``` + + + + 通过 `reference_video` 类型传入待编辑视频,结合 `prompt` 指令编辑视频内容。 + + ```bash + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "将整个画面转换为黏土风格", + "media": [ + { + "type": "reference_video", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260402/ldnfdf/wan2.7-videoedit-style-change.mp4" + } + ] + }, + "parameters": { + "resolution": "720P", + "prompt_extend": true + } + }' + ``` + + + + 通过 `reference_video` 类型传入原始视频,结合含延长意图关键词的 `prompt` 延长视频内容。需将 `ratio` 设为 `adaptive`。 + + ```bash + curl --location 'https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis' \ + -H 'X-DashScope-Async: enable' \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "wan3.0-video", + "input": { + "prompt": "将视频1向后延长,面包师端上刷好的面包,将刷子放到一旁,镜头跟随面包师,去斜后方的烤炉进行烤制", + "media": [ + { + "type": "reference_video", + "url": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260402/ldnfdf/wan2.7-videoedit-style-change.mp4" + } + ] + }, + "parameters": { + "resolution": "720P", + "ratio": "adaptive", + "prompt_extend": true + } + }' + ``` + + +
+ + + + + + + + + + + + + + + +
+ #### 响应参数 + + output `object` + + 任务输出信息。 + + + task\_id `string` + + 任务ID。查询有效期24小时。 + + task\_status `string` + + 任务状态。 + + + - PENDING:任务排队中 + - RUNNING:任务处理中 + - SUCCEEDED:任务执行成功 + - FAILED:任务执行失败 + - CANCELED:任务已取消 + - UNKNOWN:任务不存在或状态未知 + + + + request\_id`string` + + 请求唯一标识。可用于请求明细溯源和问题排查。 + + code`string` + + 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](/zh/model-studio/error-code)。 + + message`string` + + 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](/zh/model-studio/error-code)。 + + + + 请保存 task\_id,用于查询任务状态与结果。 + + ```json + { + "output": { + "task_status": "PENDING", + "task_id": "0385dc79-5ff8-4d82-bcb6-xxxxxx" + }, + "request_id": "4909100c-7b5a-9f92-bfe5-xxxxxx" + } + ``` + + + + 创建任务失败,请参见[错误码](/zh/model-studio/error-code)进行解决。 + + ```json + { + "code": "InvalidApiKey", + "message": "No API-key provided.", + "request_id": "7438d53d-6eb8-4596-8835-xxxxxx" + } + ``` + + +
+ +### 步骤2:根据任务ID查询结果 + + + + `GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + `GET https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + `GET https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + `GET https://{WorkspaceId}.eu-central-1.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + `GET https://{WorkspaceId}.us-east-1.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + `GET https://{WorkspaceId}.cn-hongkong.maas.aliyuncs.com/api/v1/tasks/{task_id}` + + + + + - 轮询建议:视频生成过程约需数分钟,建议采用轮询机制,并设置合理的查询间隔(如 15 秒)来获取结果。 + - 任务状态流转:PENDING(排队中)→ RUNNING(处理中)→ SUCCEEDED(成功)/ FAILED(失败)。 + - 结果链接:任务成功后返回视频链接,有效期为 24 小时。建议在获取链接后立即下载并转存至永久存储(如[阿里云 OSS](https://help.aliyun.com/zh/oss/user-guide/what-is-oss))。 + - task\_id 有效期:24小时,超时后将无法查询结果,接口将返回任务状态为`UNKNOWN`。 + - RPS 限制:查询接口默认RPS为20。如需更高频查询或事件通知,建议[配置异步任务回调](/zh/model-studio/async-task-api)。 + - 更多操作:如需批量查询、取消任务等操作,请参见[管理异步任务](/zh/model-studio/manage-asynchronous-tasks)。 + + + + + + + + + + + + + + + + +
+ #### 请求参数 + + ##### 请求头(Headers) + + Authorization`string`(必选) + + 请求身份认证。接口使用阿里云百炼API Key进行身份认证。示例值:Bearer sk-xxxx。 + + ##### URL路径参数(Path parameters) + + task\_id `string`(必选) + + 任务ID。 + + + + 将`{task_id}`完整替换为上一步接口返回的`task_id`的值。`task_id`查询有效期为24小时,并请将`{WorkspaceId}`替换为真实的[业务空间ID](/zh/model-studio/regions#h2_migrate_domain)。 + + ```bash + curl -X GET https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id} \ + --header "Authorization: Bearer $DASHSCOPE_API_KEY" + ``` + + +
+ + + + + + + + + + + + + + + +
+ #### 响应参数 + + output `object` + + 任务输出信息。 + + + task\_id `string`(必选) + + 任务ID。 + + task\_status `string` + + 任务状态。 + + + - PENDING:任务排队中 + - RUNNING:任务处理中 + - SUCCEEDED:任务执行成功 + - FAILED:任务执行失败 + - CANCELED:任务已取消 + - UNKNOWN:任务不存在或状态未知 + + + submit\_time `string` + + 任务提交时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 + + scheduled\_time `string` + + 任务执行时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 + + end\_time `string` + + 任务完成时间。格式为 YYYY-MM-DD HH:mm:ss.SSS。 + + orig\_prompt `string` + + 原始输入的提示词。 + + video\_url `string` + + 生成视频的URL地址。任务成功时返回。 + + code`string` + + 请求失败的错误码。请求成功时不会返回此参数,详情请参见[错误码](/zh/model-studio/error-code)。 + + message`string` + + 请求失败的详细信息。请求成功时不会返回此参数,详情请参见[错误码](/zh/model-studio/error-code)。 + + + usage `object` + + 输出信息统计。只对成功的结果计数。 + + + video\_count `integer` + + 生成视频的数量。固定为1。 + + duration `float` + + 生成视频的时长,单位为秒。 + + input\_video\_duration `float` + + 输入视频的时长,单位为秒。无视频输入时为0.0。 + + output\_video\_duration `float` + + 输出视频的时长,单位为秒。 + + fps `integer` + + 生成视频的帧率。默认值为30。 + + SR `integer` + + 生成视频的分辨率。示例值:720。 + + ratio `string` + + 生成视频的宽高比。示例值:16:9。 + + + request\_id`string` + + 请求唯一标识。可用于请求明细溯源和问题排查。 + + + + 视频URL仅保留24小时,超时后会被自动清除,请及时保存生成的视频。 + + ```json + { + "request_id": "78c9b768-0285-996c-b682-xxxxxx", + "output": { + "task_id": "17ed7e50-00cf-4509-aea1-xxxxxx", + "task_status": "SUCCEEDED", + "submit_time": "2026-08-06 10:01:35.452", + "scheduled_time": "2026-08-06 10:01:35.507", + "end_time": "2026-08-06 10:13:33.838", + "orig_prompt": "A golden retriever running on a sunny beach, waves crashing in the background, cinematic lighting", + "video_url": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxx/video.mp4" + }, + "usage": { + "video_count": 1, + "duration": 5.0, + "input_video_duration": 0.0, + "output_video_duration": 5.0, + "fps": 30, + "SR": 720, + "ratio": "16:9" + } + } + ``` + + + + 若任务执行失败,task\_status将置为 FAILED,并提供错误码和信息。请参见[错误码](/zh/model-studio/error-code)进行解决。 + + ```json + { + "request_id": "e5e57877-c0fc-47ed-8fad-xxxxxx", + "output": { + "task_id": "eff1443c-ccab-4676-aad3-xxxxxx", + "task_status": "FAILED", + "code": "InvalidParameter", + "message": "The two modes are mutually exclusive. Do not pass reference_xx and first_frame/last_frame at the same time." + } + } + ``` + + + + task\_id查询有效期为 24 小时,超时后将无法查询,返回以下报错信息。 + + ```json + { + "request_id": "a4de7c32-7057-9f82-8581-xxxxxx", + "output": { + "task_id": "502a00b1-19d9-4839-a82f-xxxxxx", + "task_status": "UNKNOWN" + } + } + ``` + + +
+ +## 素材组合 + + + 仅支持以下特定的素材组合,传入其他组合将报错。 + + +**文生视频**:仅传入 `prompt`,不支持传入 `media`。 + +```json +// 文生视频 +{ + "input": { + "prompt": "..." + } +} +``` + +**图生视频、参考生视频、视频编辑、视频延长等模式**需通过 `media` 数组传入素材,每个元素需指定 `type` 和 `url`。`type` 组合规则: + +- `first_frame`/`last_frame` 与 `reference_image`/`reference_video`/`reference_audio`/`file`/`link` 类型互斥,不能在同一请求中混用。 +- `file` 和 `link` 二选一,但均可与 `reference_image`/`reference_video`/`reference_audio` 组合使用。 +- `reference_image`/`reference_video`/`reference_audio` 之间可自由组合。 + + + ```json 首帧生视频 + // 仅首帧 + { + "media": [ + { "type": "first_frame", "url": "..." } + ] + } + ``` + + ```json 首尾帧生视频 + // 仅首帧+尾帧 + { + "media": [ + { "type": "first_frame", "url": "..." }, + { "type": "last_frame", "url": "..." } + ] + } + ``` + + ```json 参考生视频 + // reference_image、reference_video、reference_audio 可自由组合 + + // 仅参考图片 + { + "media": [ + { "type": "reference_image", "url": "..." } + ] + } + + // 仅参考视频 + { + "media": [ + { "type": "reference_video", "url": "..." } + ] + } + + // 仅参考音频 + { + "media": [ + { "type": "reference_audio", "url": "..." } + ] + } + + // 图片+视频 + { + "media": [ + { "type": "reference_image", "url": "..." }, + { "type": "reference_video", "url": "..." } + ] + } + + // 图片+音频 + { + "media": [ + { "type": "reference_image", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 视频+音频 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 图片+视频+音频 + { + "media": [ + { "type": "reference_image", "url": "..." }, + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + ``` + + ```json 文件/网页生视频 + // 必须包含 file 或 link(二选一),可与 reference_image/reference_video/reference_audio 自由组合 + + // 仅文件 + { + "media": [ + { "type": "file", "url": "..." } + ] + } + + // 文件+图片 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_image", "url": "..." } + ] + } + + // 文件+视频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_video", "url": "..." } + ] + } + + // 文件+音频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 文件+图片+视频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_image", "url": "..." }, + { "type": "reference_video", "url": "..." } + ] + } + + // 文件+图片+音频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_image", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 文件+视频+音频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 文件+图片+视频+音频 + { + "media": [ + { "type": "file", "url": "..." }, + { "type": "reference_image", "url": "..." }, + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + ``` + + ```json 视频编辑 + // 必须包含 reference_video,可与 reference_image/reference_audio 组合 + // 提示词含编辑意图 + + // 仅参考视频 + { + "media": [ + { "type": "reference_video", "url": "..." } + ] + } + + // 视频+图片 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_image", "url": "..." } + ] + } + + // 视频+音频 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 视频+图片+音频 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_image", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + ``` + + ```json 视频延长 + // 必须包含 reference_video,可与 reference_image/reference_audio 组合 + // 提示词含延长意图 + + // 仅参考视频 + { + "media": [ + { "type": "reference_video", "url": "..." } + ] + } + + // 视频+图片 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_image", "url": "..." } + ] + } + + // 视频+音频 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + + // 视频+图片+音频 + { + "media": [ + { "type": "reference_video", "url": "..." }, + { "type": "reference_image", "url": "..." }, + { "type": "reference_audio", "url": "..." } + ] + } + ``` + + +## 常见问题 + +### 首尾帧模式下为什么不能同时传入音频等其他媒体素材? + +万相3.0 的首帧/首尾帧模式仅支持传入 `first_frame` 和 `last_frame`,不可同时传入音频等其他类型(详见[素材组合](#w30media_combo_t))。 + +与[万相2.7图生视频系列](/zh/model-studio/wan-image-to-video-guide)的区别:wan2.7 首帧/首尾帧模式支持同时传入 `driving_audio` 驱动音频;万相3.0 首帧/首尾帧模式不支持,如需音频驱动,请使用全模态参考模式(`reference_image` + `reference_audio` 组合)替代首尾帧模式。具体用法请参见[万相3.0-视频生成使用指南](/zh/model-studio/wan3-video-generation-guide)。 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.go b/internal/cmd/cmd.go index 88a6a1a..8be7637 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "net/http" + "net/http/pprof" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/response" "github.com/qianfree/team-api/internal/utility/crypto" + relaycommon "github.com/qianfree/team-api/relay/common" adminHandler "github.com/qianfree/team-api/internal/handler/admin" "github.com/qianfree/team-api/internal/handler/landing" @@ -120,7 +122,7 @@ var ( common.InitChannelErrorWriter() // Initialize async audit log writer - common.InitAuditLogWriter() + common.InitAuditLogWriter(ctx) // Initialize async channel debug log writer + 注入 relay 层提交钩子 common.InitChannelDebugLogWriter() @@ -129,12 +131,36 @@ var ( // Initialize async error log writer response.InitErrorLogWriter() + // 按配置拉起 pprof 调试端口(「性能配置-调试」):仅监听本机回环,禁止公网暴露; + // 端口在进程启动时读取,变更后需重启生效 + if port := common.Config().GetInt(ctx, "pprof_port"); port > 0 && port <= 65535 { + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + if listenErr := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", port), mux); listenErr != nil { + g.Log().Warningf(ctx, "pprof server exited: %v", listenErr) + } + }() + g.Log().Infof(ctx, "pprof listening on 127.0.0.1:%d", port) + } + // Register cron jobs common.InitCronScheduler() cs := common.GetCronScheduler() registerCronJobs(cs) cs.StartBackground(ctx) + // relay 层运行时配置注入(全局超时兜底/系统代理;渠道级 settings 仍优先) + syncGlobalRelaySettings(ctx) + + // 累计消费基线种子:为存量钱包 hash 补种 total_consumed 历史基线(幂等), + // 必须在 relay 流量进入前完成,防止结算 HINCRBY 从 0 起算丢历史 + billing.SeedWalletTotalConsumed(ctx) + // 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron) billing.StartWalletMaterializer(ctx) @@ -223,7 +249,10 @@ var ( // Tenant — public endpoints use g.Meta middleware:"-" to skip auth group.Group("/tenant", func(g *ghttp.RouterGroup) { - g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency) + // OperationLog 与管理后台共用:中间件按 ctx 的 userType/tenantId 落 + // aud_operation_logs,租户写操作(成员/Key/工单等)此前完全无留痕, + // 且租户端"操作日志"页因 tenant_id 恒为 0 永远为空,挂上后一并修复。 + g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency, middleware.OperationLog) g.Bind(tenantController.NewV1()) }) @@ -255,9 +284,14 @@ var ( }) // Open Platform API — HMAC-SHA256 authentication + // 中间件与 /api/tenant 对齐:演示模式同样要拦住写操作(否则演示部署可经开放平台 + // 真实改数据),维护模式头同样要下发,幂等同样要生效 —— 开放平台是对外的、 + // 天然会重试的接口,没有幂等语义比控制台更危险。 + // Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。 s.Group("/api/open", func(group *ghttp.RouterGroup) { group.Middleware(middleware.MiddlewareHandlerResponse) - group.Middleware(middleware.OpenPlatformAuth) + // OperationLog 以应用身份(open_app + 应用ID)记录写操作,与管理/租户端共用 + group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency, middleware.OperationLog) group.Bind(openController.NewV1()) }) @@ -332,7 +366,7 @@ func printBanner() { fmt.Printf(" %sTeam-API%s %s%s%s %s| %s%s企业级大模型 API 网关系统%s\n", cyan, reset, green, consts.Version, reset, dim, reset, dim, reset) fmt.Printf(" %shttps://github.com/qianfree/team-api%s\n", dim, reset) fmt.Println() - fmt.Printf(" %sAGPL v3.0 开源协议 | Copyright © 2025-2026 Team-API Contributors%s\n", dim, reset) + fmt.Printf(" %sAGPL-3.0-or-later | Copyright © 2026 qianfree%s\n", dim, reset) fmt.Println() } @@ -363,6 +397,30 @@ func watchChannelAutoTestInterval(cs *common.CronScheduler) { }) } +// syncGlobalRelaySettings 将系统设置中 relay 层的运行时配置注入 relay 层,并在配置变更时动态刷新(免重启): +// - 全局超时:优先级 渠道级 settings.timeout_seconds > 此处注入的全局值 > relay 层内置默认(180s/300s) +// - 系统代理:channel_proxy_url,渠道编辑中开启「使用代理」的请求经此代理转发 +// +// relay 包不反向 import internal(会循环依赖),故由本函数桥接注入。 +func syncGlobalRelaySettings(ctx context.Context) { + apply := func(ctx context.Context) { + relaycommon.SetGlobalRequestTimeoutSeconds(common.Config().GetInt(ctx, "request_timeout_seconds")) + relaycommon.SetGlobalStreamIdleTimeoutSeconds(common.Config().GetInt(ctx, "streaming_timeout_seconds")) + relaycommon.SetSystemProxyURL(common.Config().GetString(ctx, "channel_proxy_url")) + } + apply(ctx) + common.OnSettingsChanged(func(ctx context.Context, key string) { + if key != "request_timeout_seconds" && key != "streaming_timeout_seconds" && key != "channel_proxy_url" { + return + } + apply(ctx) + // 代理 URL 可能含认证凭据,只记录是否已配置,不落日志 + g.Log().Infof(ctx, "全局 relay 配置已刷新: request=%ds stream_idle=%ds proxy_configured=%v", + relaycommon.GlobalRequestTimeoutSeconds(), relaycommon.GlobalStreamIdleTimeoutSeconds(), + relaycommon.GetSystemProxyURL() != "") + }) +} + // registerCronJobs 集中注册所有定时任务,避免散落在主启动流程中。 // 新增 cron 任务时在此追加一项即可;任务名(用于分布式锁 key)需保持唯一且稳定。 func registerCronJobs(cs *common.CronScheduler) { @@ -462,7 +520,7 @@ func registerPaymentCallbacks(group *ghttp.RouterGroup) { // registerRelayRoutes registers AI proxy routes (/v1/xxx). func registerRelayRoutes(server *ghttp.Server) { server.Group("/v1", func(group *ghttp.RouterGroup) { - group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) group.POST("/chat/completions", relay.HandleChatCompletions) group.GET("/models", relay.HandleModels) @@ -477,6 +535,7 @@ func registerRelayRoutes(server *ghttp.Server) { group.POST("/responses/{id}/cancel", relay.HandleResponsesCancel) group.DELETE("/responses/{id}", relay.HandleResponsesDelete) group.POST("/messages", relay.HandleMessages) + group.POST("/messages/count_tokens", relay.HandleCountTokens) group.POST("/audio/speech", relay.HandleAudioSpeech) group.POST("/audio/transcriptions", relay.HandleAudioTranscription) group.POST("/audio/translations", relay.HandleAudioTranslation) @@ -484,11 +543,16 @@ func registerRelayRoutes(server *ghttp.Server) { group.POST("/moderations", relay.HandleModerations) group.POST("/images/edits", relay.HandleImagesEdits) group.GET("/realtime", relay.HandleRealtime) + // OpenAI Videos 协议端点(官方 SDK 兼容,对接第三方视频模型) + group.POST("/videos", relay.HandleVideoCreate) + group.GET("/videos/{video_id}", relay.HandleVideoRetrieve) + group.GET("/videos/{video_id}/content", relay.HandleVideoContent) + group.DELETE("/videos/{video_id}", relay.HandleVideoDelete) }) // Gemini 兼容路由(/v1beta/models/{model}:generateContent) server.Group("/v1beta", func(group *ghttp.RouterGroup) { - group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) group.GET("/models", relay.HandleGeminiModels) group.GET("/models/{model}", relay.HandleGeminiModelDetail) group.POST("/models/{model}", relay.HandleGeminiGenerateContent) @@ -496,25 +560,49 @@ func registerRelayRoutes(server *ghttp.Server) { // 异步任务端点(视频/音乐生成) server.Group("/v1", func(group *ghttp.RouterGroup) { - group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) group.POST("/video/generations", relay.HandleTaskSubmit) group.GET("/video/generations/{task_id}", relay.HandleTaskFetch) }) // 异步图片生成端点(阿里云 DashScope 等) server.Group("/v1", func(group *ghttp.RouterGroup) { - group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) group.POST("/images/generations/async", relay.HandleAliImageSubmit) group.GET("/images/generations/async/{task_id}", relay.HandleTaskFetch) }) // Suno 端点 server.Group("/suno", func(group *ghttp.RouterGroup) { - group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) group.POST("/submit/{action}", relay.HandleTaskSubmit) group.POST("/fetch", relay.HandleSunoFetchBatch) group.GET("/fetch/{task_id}", relay.HandleTaskFetch) }) + + // MiniMax 官方视频协议端点(H3 v2,官方 SDK 换 base_url 直连;协议文档 docs/modeldocs/minimax/video/) + server.Group("/v2", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) + group.POST("/video_generation", relay.HandleMiniMaxVideoSubmit) + group.GET("/query/video_generation/{task_id}", relay.HandleMiniMaxVideoRetrieve) + group.DELETE("/video_generation/{task_id}", relay.HandleMiniMaxVideoCancel) + }) + + // MiniMax 官方视频协议端点(Hailuo 系列 v1,扁平字段协议;查询走 query 参数, + // 成品经 files/retrieve 二跳由网关轮询时代取,官方无取消/删除端点) + server.Group("/v1", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) + group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit) + group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve) + }) + + // 阿里 DashScope 官方视频协议端点(wan2.x / wan3.0 通用,阿里 SDK 换 base_url 直连; + // 查询回放官方响应形态,轮询计费由网关后台完成) + server.Group("/v1", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter) + group.POST("/services/aigc/video-generation/video-synthesis", relay.HandleAliVideoSubmit) + group.GET("/tasks/{task_id}", relay.HandleAliVideoRetrieve) + }) } // registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页, 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/consts/consts.go b/internal/consts/consts.go index 98c4549..1e73ac2 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -150,18 +150,17 @@ const ( CodeInvalidConfirmToken = 10046 // Security errors (10047+) - CodeTotpRequired = 10047 - CodeTotpInvalid = 10048 - CodeTotpAlreadyEnabled = 10049 - CodeTotpNotEnabled = 10050 - CodeBackupCodeInvalid = 10051 - CodeTurnstileFailed = 10052 - CodeIpRestricted = 10053 - CodeHighRisk2FARequired = 10054 - CodeNoAvailableApiKey = 10055 - CodeSandboxQuotaExceeded = 10056 - CodeCaptchaRequired = 10057 - CodeCaptchaFailed = 10058 + CodeTotpRequired = 10047 + CodeTotpInvalid = 10048 + CodeTotpAlreadyEnabled = 10049 + CodeTotpNotEnabled = 10050 + CodeBackupCodeInvalid = 10051 + CodeTurnstileFailed = 10052 + CodeIpRestricted = 10053 + CodeHighRisk2FARequired = 10054 + CodeNoAvailableApiKey = 10055 + CodeCaptchaRequired = 10057 + CodeCaptchaFailed = 10058 // OAuth errors (10059+) CodeOAuthDisabled = 10059 @@ -183,6 +182,7 @@ const ( CodeHelpCategorySlugExists = 10074 CodeHelpArticleNotFound = 10075 CodeHelpArticleSlugExists = 10076 + CodeHelpRateLimitExceeded = 10099 // Registration errors CodeRegistrationDisabled = 10077 @@ -338,6 +338,7 @@ const ( MsgHelpCategorySlugExists = "分类标识已存在" MsgHelpArticleNotFound = "帮助文章不存在" MsgHelpArticleSlugExists = "文章标识已存在" + MsgHelpRateLimitExceeded = "访问过于频繁,请稍后再试" // Registration errors MsgRegistrationDisabled = "注册功能已关闭" MsgEmailVerificationDisabled = "注册邮箱验证未启用" diff --git a/internal/controller/admin/admin_v1_audit.go b/internal/controller/admin/admin_v1_audit.go index e2346d9..5517f3c 100644 --- a/internal/controller/admin/admin_v1_audit.go +++ b/internal/controller/admin/admin_v1_audit.go @@ -34,3 +34,6 @@ func (c *ControllerV1) ContentFilterLogList(ctx context.Context, req *v1.Content func (c *ControllerV1) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) { return service.Admin().ForwardingTraceGet(ctx, req) } +func (c *ControllerV1) ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) { + return service.Admin().ContentFilterLogClear(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_auth.go b/internal/controller/admin/admin_v1_auth.go index 4c415fe..3f44741 100644 --- a/internal/controller/admin/admin_v1_auth.go +++ b/internal/controller/admin/admin_v1_auth.go @@ -28,3 +28,6 @@ func (c *ControllerV1) AdminForceLogout(ctx context.Context, req *v1.AdminForceL func (c *ControllerV1) AdminChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (res *v1.AdminChangePasswordRes, err error) { return service.Admin().ChangePassword(ctx, req) } +func (c *ControllerV1) AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) { + return service.Admin().GetMe(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel.go b/internal/controller/admin/admin_v1_channel.go index b6539e8..c60afcc 100644 --- a/internal/controller/admin/admin_v1_channel.go +++ b/internal/controller/admin/admin_v1_channel.go @@ -52,3 +52,6 @@ func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) { return service.Admin().ResetChannelHealth(ctx, req) } +func (c *ControllerV1) ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) { + return service.Admin().ListChannelOptions(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_channel_error.go b/internal/controller/admin/admin_v1_channel_error.go index 51f3ed7..697699f 100644 --- a/internal/controller/admin/admin_v1_channel_error.go +++ b/internal/controller/admin/admin_v1_channel_error.go @@ -22,3 +22,6 @@ func (c *ControllerV1) ChannelErrorTopChannels(ctx context.Context, req *v1.Chan func (c *ControllerV1) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) { return service.Admin().ChannelErrorCategories(ctx, req) } +func (c *ControllerV1) ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) { + return service.Admin().ChannelErrorClear(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_dashboard.go b/internal/controller/admin/admin_v1_dashboard.go index cdbdca8..59c7422 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -73,3 +73,6 @@ func (c *ControllerV1) AdminWalletFrozenReleaseAll(ctx context.Context, req *v1. func (c *ControllerV1) AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) { return service.Admin().GetUsageLogSummary(ctx, req) } +func (c *ControllerV1) AdminUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (res *v1.AdminUsageLogDetailRes, err error) { + return service.Admin().GetUsageLogDetail(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_permission.go b/internal/controller/admin/admin_v1_permission.go index aea4afe..c94c44b 100644 --- a/internal/controller/admin/admin_v1_permission.go +++ b/internal/controller/admin/admin_v1_permission.go @@ -13,9 +13,6 @@ func (c *ControllerV1) AdminPermissionList(ctx context.Context, req *v1.AdminPer func (c *ControllerV1) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) { return service.Admin().UpdateUserPermissions(ctx, req) } -func (c *ControllerV1) AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) { - return service.Admin().UpdateUserDataScopes(ctx, req) -} func (c *ControllerV1) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) { return service.Admin().GetAllPermissions(ctx, req) } diff --git a/internal/controller/admin/admin_v1_role.go b/internal/controller/admin/admin_v1_role.go new file mode 100644 index 0000000..44faabb --- /dev/null +++ b/internal/controller/admin/admin_v1_role.go @@ -0,0 +1,36 @@ +package admin + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) { + return service.Admin().ListRoles(ctx, req) +} +func (c *ControllerV1) AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) { + return service.Admin().GetRoleDetail(ctx, req) +} +func (c *ControllerV1) AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) { + return service.Admin().CreateRole(ctx, req) +} +func (c *ControllerV1) AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) { + return service.Admin().UpdateRole(ctx, req) +} +func (c *ControllerV1) AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) { + return service.Admin().DeleteRole(ctx, req) +} +func (c *ControllerV1) AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) { + return service.Admin().UpdateRoleStatus(ctx, req) +} +func (c *ControllerV1) AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) { + return service.Admin().ResetRoleDefaults(ctx, req) +} +func (c *ControllerV1) AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) { + return service.Admin().GetUserRoles(ctx, req) +} +func (c *ControllerV1) AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) { + return service.Admin().AssignUserRoles(ctx, req) +} diff --git a/internal/controller/admin/admin_v1_support_pending.go b/internal/controller/admin/admin_v1_support_pending.go new file mode 100644 index 0000000..cd16028 --- /dev/null +++ b/internal/controller/admin/admin_v1_support_pending.go @@ -0,0 +1,12 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) { + return service.Admin().GetSupportPendingSummary(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_auth.go b/internal/controller/tenant/tenant_v1_auth.go index be93aec..6d959a8 100644 --- a/internal/controller/tenant/tenant_v1_auth.go +++ b/internal/controller/tenant/tenant_v1_auth.go @@ -28,3 +28,6 @@ func (c *ControllerV1) TenantSessionList(ctx context.Context, req *v1.TenantSess func (c *ControllerV1) TenantRevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (res *v1.TenantRevokeSessionRes, err error) { return service.Tenant().RevokeSession(ctx, req) } +func (c *ControllerV1) TenantRegisterRateLimit(ctx context.Context, req *v1.TenantRegisterRateLimitReq) (res *v1.TenantRegisterRateLimitRes, err error) { + return service.Tenant().GetRegisterRateLimitStatus(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_billing_tenant.go b/internal/controller/tenant/tenant_v1_billing_tenant.go index 808792f..761c216 100644 --- a/internal/controller/tenant/tenant_v1_billing_tenant.go +++ b/internal/controller/tenant/tenant_v1_billing_tenant.go @@ -13,3 +13,9 @@ func (c *ControllerV1) TenantUsageLogs(ctx context.Context, req *v1.TenantUsageL func (c *ControllerV1) TenantUsageLogsExport(ctx context.Context, req *v1.TenantUsageLogsExportReq) (res *v1.TenantUsageLogsExportRes, err error) { return service.Tenant().ExportUsageLogs(ctx, req) } +func (c *ControllerV1) TenantUsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (res *v1.TenantUsageLogsSummaryRes, err error) { + return service.Tenant().UsageLogsSummary(ctx, req) +} +func (c *ControllerV1) TenantUsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (res *v1.TenantUsageLogDetailRes, err error) { + return service.Tenant().UsageLogDetail(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_playground.go b/internal/controller/tenant/tenant_v1_playground.go deleted file mode 100644 index 8da7cec..0000000 --- a/internal/controller/tenant/tenant_v1_playground.go +++ /dev/null @@ -1,15 +0,0 @@ -package tenant - -import ( - "context" - - v1 "github.com/qianfree/team-api/api/tenant/v1" - "github.com/qianfree/team-api/internal/service" -) - -func (c *ControllerV1) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) { - return service.Tenant().SandboxChat(ctx, req) -} -func (c *ControllerV1) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) { - return service.Tenant().SandboxQuota(ctx, req) -} diff --git a/internal/dao/internal/aud_request_logs.go b/internal/dao/internal/aud_request_logs.go index f62e8d1..5612da0 100644 --- a/internal/dao/internal/aud_request_logs.go +++ b/internal/dao/internal/aud_request_logs.go @@ -51,6 +51,7 @@ type AudRequestLogsColumns struct { TaskResult string // 异步任务完成时上游返回的原始响应体 TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt string // 异步任务达到终态的时间 + ModelName string // 请求使用的模型名(用户请求体中的原始模型,未经渠道映射) } // audRequestLogsColumns holds the columns for the table aud_request_logs. @@ -85,6 +86,7 @@ var audRequestLogsColumns = AudRequestLogsColumns{ TaskResult: "task_result", TaskUpstreamHeaders: "task_upstream_headers", TaskCompletedAt: "task_completed_at", + ModelName: "model_name", } // NewAudRequestLogsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/bil_usage_logs.go b/internal/dao/internal/bil_usage_logs.go index 8b5b155..ba50efa 100644 --- a/internal/dao/internal/bil_usage_logs.go +++ b/internal/dao/internal/bil_usage_logs.go @@ -78,6 +78,8 @@ type BilUsageLogsColumns struct { BillingSnapshot string // 完整计费计算过程快照(JSONB) ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目) TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空 + DurationSeconds string // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL + UpstreamRequestId string // 上游请求ID:对话=上游响应头请求ID(X-Oneapi-Request-Id等),任务=上游任务ID;排障反查用 } // bilUsageLogsColumns holds the columns for the table bil_usage_logs. @@ -139,6 +141,8 @@ var bilUsageLogsColumns = BilUsageLogsColumns{ BillingSnapshot: "billing_snapshot", ProjectId: "project_id", TaskId: "task_id", + DurationSeconds: "duration_seconds", + UpstreamRequestId: "upstream_request_id", } // NewBilUsageLogsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/bil_wallets.go b/internal/dao/internal/bil_wallets.go index 2d84fa0..f5d8ceb 100644 --- a/internal/dao/internal/bil_wallets.go +++ b/internal/dao/internal/bil_wallets.go @@ -31,6 +31,7 @@ type BilWalletsColumns struct { UpdatedAt string // 更新时间 CumulativeRecharge string // 累计充值总额(USD) LowBalanceNotified string // 低余额预警是否已推送(充值恢复后重置为 false) + TotalConsumed string // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减) } // bilWalletsColumns holds the columns for the table bil_wallets. @@ -45,6 +46,7 @@ var bilWalletsColumns = BilWalletsColumns{ UpdatedAt: "updated_at", CumulativeRecharge: "cumulative_recharge", LowBalanceNotified: "low_balance_notified", + TotalConsumed: "total_consumed", } // NewBilWalletsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_models.go b/internal/dao/internal/mdl_models.go index 88fe9ec..49998fe 100644 --- a/internal/dao/internal/mdl_models.go +++ b/internal/dao/internal/mdl_models.go @@ -36,6 +36,7 @@ type MdlModelsColumns struct { DeprecatedAt string // 标记弃用的时间(NULL表示未弃用) SunsetDate string // 计划下线日期(到达后返回410 Gone,NULL表示未设置) ReplacementModel string // 推荐替代模型名(NULL表示无替代) + Vendor string // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类 } // mdlModelsColumns holds the columns for the table mdl_models. @@ -55,6 +56,7 @@ var mdlModelsColumns = MdlModelsColumns{ DeprecatedAt: "deprecated_at", SunsetDate: "sunset_date", ReplacementModel: "replacement_model", + Vendor: "vendor", } // NewMdlModelsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_pricing.go b/internal/dao/internal/mdl_pricing.go index dcdf6d2..e5e754d 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -21,42 +21,30 @@ type MdlPricingDao struct { // MdlPricingColumns defines and stores column names for the table mdl_pricing. type MdlPricingColumns struct { - Id string // - ModelId string // 关联模型ID - BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量) - MinTokens string // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0) - MaxTokens string // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式) - InputPrice string // 每 1M input token 价格(token/tiered 模式) - OutputPrice string // 每 1M output token 价格(token/tiered 模式) - PerRequestPrice string // 按次计费单价(仅 per_request 模式) - CreatedAt string // - UpdatedAt string // - CacheReadPrice string // 缓存读取每 1M token 价格(直接定价) - CacheCreationPrice string // 缓存创建每 1M token 价格(直接定价) - TimeSegments string // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end 0 && common.GetCtxUserRole(ctx) != "super_admin" { + return nil, common.NewBusinessError(consts.CodeForbidden, "分配角色仅超级管理员可用,请先创建账号再由超级管理员分配角色") + } + + // 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403, + // 是「系统缺乏角色管理」最直接的体感来源。 + var id int64 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{ + Username: username, + PasswordHash: passwordHash, + Email: email, + DisplayName: username, + Role: role, + Status: "active", + }).Insert() + if err != nil { + // Race condition: another request inserted a colliding username/email + // between our pre-check and this insert. Translate the DB unique + // violation into a friendly business error. + if common.IsDuplicateKeyError(err) { + return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists) + } + return err + } + + id, err = result.LastInsertId() + if err != nil { + return err + } + + // 超级管理员权限来自账号属性而非角色,忽略传入的 role_ids + if role == "super_admin" || len(req.RoleIDs) == 0 { + return nil + } + return replaceUserRoles(ctx, id, req.RoleIDs) + }) + if err != nil { return nil, err } - id, _ := result.LastInsertId() return &v1.AdminUserCreateRes{ID: id}, nil } @@ -166,6 +265,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v if targetUser.Role == "super_admin" { return nil, common.NewBadRequestError("不能修改超级管理员信息") } + if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil { + return nil, err + } data := do.SysAdminUsers{} if req.DisplayName != nil { @@ -188,6 +290,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v if err := common.ValidateAdminRole(*req.Role); err != nil { return nil, common.NewBadRequestError("角色无效") } + if err := assertCanGrantSuperAdmin(ctx, *req.Role); err != nil { + return nil, err + } data.Role = *req.Role } @@ -225,14 +330,34 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v if user.Role == "super_admin" { return nil, common.NewBadRequestError("不能删除超级管理员") } + if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { + return nil, err + } // Delete user first, then revoke sessions to avoid leaving the user // in a state where sessions are gone but the user still exists. - _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete() + // + // 权限相关记录同事务清理:这些表都没有外键,数据库不会代劳, + // 漏删会留下悬空的角色关联与特批权限。 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminRolePerms.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil { + return err + } + _, err := dao.SysAdminDataScopes.Ctx(ctx).Where("admin_user_id", req.Id).Delete() + return err + }) if err != nil { return nil, err } + InvalidateUserPermCache(ctx, req.Id) + // Revoke all sessions common.RevokeAllSessions(ctx, "admin", req.Id) @@ -266,6 +391,9 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt if user.Role == "super_admin" { return nil, common.NewBadRequestError("不能修改超级管理员状态") } + if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { + return nil, err + } _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{ Status: req.Status, @@ -283,17 +411,24 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt } // UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。 +// +// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解 +// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。 func (s *sAdmin) UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) { var user *struct { - Id int64 `json:"id"` + Id int64 `json:"id"` + Role string `json:"role"` } - err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Scan(&user) + err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("id, role").Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if user == nil { return nil, common.NewNotFoundError("管理员") } + if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { + return nil, err + } _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Data(map[string]interface{}{ "failed_attempts": 0, @@ -311,6 +446,21 @@ func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPa return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak) } + // 改密等于账号接管:此前不校验目标是谁,拿到 user:edit 就能重置超级管理员的密码。 + var targetUser *struct { + Role string `json:"role"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("role").Scan(&targetUser) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if targetUser == nil { + return nil, common.NewNotFoundError("管理员") + } + if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil { + return nil, err + } + passwordHash, err := crypto.HashPassword(req.NewPassword) if err != nil { return nil, err @@ -367,15 +517,15 @@ func (s *sAdmin) ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (* for { m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status) var batch []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - LastLoginAt *gtime.Time `json:"last_login_at"` - LastLoginIp string `json:"last_login_ip"` - CreatedAt *gtime.Time `json:"created_at"` + Id int64 `json:"id" orm:"id"` + Username string `json:"username" orm:"username"` + Email string `json:"email" orm:"email"` + DisplayName string `json:"display_name" orm:"display_name"` + Role string `json:"role" orm:"role"` + Status string `json:"status" orm:"status"` + LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"` + LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"` + CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` } if err := m.OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil { return diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index 808998c..572270e 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -260,24 +259,6 @@ func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveL }, nil } -// LogSensitiveAccess inserts a sensitive data access log entry. -func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason, ip, userAgent string) error { - _, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{ - UserId: userID, - UserType: userType, - ResourceType: resourceType, - ResourceId: resourceID, - Action: action, - Reason: reason, - IpAddress: ip, - UserAgent: userAgent, - }).Insert() - if err != nil { - return gerror.Wrapf(err, "记录敏感数据访问日志失败") - } - return nil -} - // MaskSensitiveData 委托给 common.MaskSensitiveData func MaskSensitiveData(data string) string { return common.MaskSensitiveData(data) @@ -356,6 +337,10 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL conditions = append(conditions, "path LIKE ?") args = append(args, "%"+req.Path+"%") } + if req.Model != "" { + conditions = append(conditions, "model_name = ?") + args = append(args, req.Model) + } if req.StatusCode > 0 { conditions = append(conditions, "status_code = ?") args = append(args, req.StatusCode) @@ -370,12 +355,19 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL } where := strings.Join(conditions, " AND ") - auditFields := "id, tenant_id, user_id, api_key_id, project_id, request_id, method, path, query_params, status_code, client_ip, user_agent, latency_ms, first_token_ms, audit_level, created_at" + // 列表白名单:表格列 + EnrichAuditRecords 回填名称所需的关联键。 + // user_id / project_id 仅作关联查询键(username / project_name 据此回填),表格不展示,出参剔除; + // request_id / query_params / user_agent 等列表不展示的字段经详情接口获取。 + auditFields := "id, tenant_id, user_id, api_key_id, project_id, method, path, model_name, status_code, client_ip, latency_ms, first_token_ms, audit_level, created_at" items, total, err := queryAuditPage(ctx, common.GetAuditDB(), "aud_request_logs", auditFields, where, args, page, pageSize) if err != nil { return nil, err } + for _, item := range items { + delete(item, "user_id") + delete(item, "project_id") + } return &v1.RequestAuditLogListRes{ List: items, @@ -397,6 +389,9 @@ func (s *sAdmin) GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAu if record == nil { return nil, common.NewNotFoundError("审计日志") } + // 详情含请求/响应体(full 级别下为明文对话内容),属敏感读取,落留痕 + common.LogSensitiveAccess(ctx, common.GetCtxUserID(ctx), "admin", "request_log", req.Id, "view", "查看请求审计日志详情") + b, _ := json.Marshal(record) var detail map[string]any _ = json.Unmarshal(b, &detail) @@ -600,3 +595,18 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter PageSize: pageSize, }, nil } + +// ContentFilterLogClear 硬删除全部内容过滤拦截日志。 +// 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间 +// (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。 +// 表无数据库级外键,TRUNCATE 安全。 +func (s *sAdmin) ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) { + count, err := dao.AudContentFilterLogs.Ctx(ctx).Count() + if err != nil { + return nil, err + } + if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE aud_content_filter_logs RESTART IDENTITY"); err != nil { + return nil, err + } + return &v1.ContentFilterLogClearRes{Deleted: int64(count)}, nil +} diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 5fb7c01..660083f 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -84,12 +84,13 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog if !crypto.VerifyPassword(req.Password, user.PasswordHash) { _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "密码错误") - // 原子递增失败次数 + // 原子递增失败次数;锁定阈值与时长取自安全配置(sys_options) updateData := do.SysAdminUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")} failedAttempts := user.FailedAttempts + 1 + maxAttempts, lockoutMinutes := common.LoginLockoutPolicy(ctx) - if failedAttempts >= 5 { - lockedUntil := time.Now().Add(30 * time.Minute) + if failedAttempts >= maxAttempts { + lockedUntil := time.Now().Add(time.Duration(lockoutMinutes) * time.Minute) updateData.LockedUntil = gtime.NewFromTime(lockedUntil) _, err := dao.SysAdminUsers.Ctx(ctx). Where("id", user.Id). @@ -98,7 +99,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog g.Log().Errorf(ctx, "更新管理员账号锁定状态失败: %v", err) } return nil, common.NewBusinessError(consts.CodeAccountLocked, - "连续 5 次密码错误,账号已锁定 30 分钟") + fmt.Sprintf("连续 %d 次密码错误,账号已锁定 %d 分钟", maxAttempts, lockoutMinutes)) } _, err := dao.SysAdminUsers.Ctx(ctx). @@ -160,6 +161,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog if err != nil { return nil, gerror.Wrapf(err, "create session") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) // Generate token pair tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti) @@ -200,6 +202,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role + res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role) // 检查待接受协议 if common.Config().GetBool(ctx, "agreement_enabled") { @@ -239,13 +242,14 @@ func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLog // Refresh handles token refresh. func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) { - refreshTokenHash := common.HashRefreshToken(req.RefreshToken) - - // Find session by refresh token hash - session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash) + // 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销 + session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken) if err != nil { return nil, common.NewUnauthorizedError("会话不存在") } + if replayed { + return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录") + } if session == nil { return nil, common.NewUnauthorizedError("会话已过期或不存在") } @@ -259,17 +263,10 @@ func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.Admi return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked) } - // Generate new refresh token - newRefreshToken, err := common.GenerateRefreshToken() - if err != nil { - return nil, err - } - newRefreshTokenHash := common.HashRefreshToken(newRefreshToken) - - // Rotate session + // Rotate session(新令牌内嵌会话 ID,供下次重放检测定位) ipAddress := g.RequestFromCtx(ctx).GetClientIp() deviceInfo := common.ExtractDeviceInfo(ctx) - err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo) + newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo) if err != nil { return nil, err } @@ -342,9 +339,9 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) ids = append(ids, id) } var users []entity.SysAdminUsers - _ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name").WhereIn("id", ids).Scan(&users) + _ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name, role").WhereIn("id", ids).Scan(&users) for _, u := range users { - userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName} + userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName, Role: u.Role} } } @@ -355,6 +352,7 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) UserId: sess.UserId, Username: userMap[sess.UserId].Username, DisplayName: userMap[sess.UserId].DisplayName, + Role: userMap[sess.UserId].Role, IpAddress: sess.IpAddress, DeviceInfo: sess.DeviceInfo, IsCurrent: sess.Id == currentSessionID, @@ -375,6 +373,31 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq) }, nil } +// assertCanOperateSessions 校验当前调用者能否处置目标管理员账号的会话。 +// +// 超级管理员的登录态任何人都不能代为操作(包括其他超管):踢掉超管会话等同于 +// 掐断平台最高权限入口,一旦下放就存在「借会话管理打击超管」的滥用路径。 +// 唯一例外是账号本人管理自己的登录设备(等同在其他设备登出)。 +// 查看不受限:会话列表对超管账号仍然展示,只是不可操作。 +func assertCanOperateSessions(ctx context.Context, targetUserID int64) error { + if targetUserID == 0 { + return nil + } + var user *entity.SysAdminUsers + err := dao.SysAdminUsers.Ctx(ctx).Where("id", targetUserID).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + // 目标账号不存在或不是超管:无可保护对象,交由路由层 user:edit 把关 + if user == nil || user.Role != "super_admin" { + return nil + } + if common.GetCtxUserID(ctx) == targetUserID { + return nil + } + return common.NewBusinessError(consts.CodeForbidden, "超级管理员的会话仅限本人管理,其他人只能查看") +} + // RevokeSession revokes a specific session. func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionReq) (*v1.AdminRevokeSessionRes, error) { // Look up session to get jti for Redis revocation @@ -383,6 +406,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe return nil, err } if sess != nil { + if err := assertCanOperateSessions(ctx, sess.UserId); err != nil { + return nil, err + } common.MarkSessionRevoked(ctx, sess.Jti) } err = common.RevokeSession(ctx, "admin", req.Id) @@ -394,6 +420,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe // ForceLogout revokes all sessions for a specific user. func (s *sAdmin) ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) { + if err := assertCanOperateSessions(ctx, req.Id); err != nil { + return nil, err + } err := common.RevokeAllSessions(ctx, "admin", req.Id) if err != nil { return nil, err @@ -447,3 +476,67 @@ func (s *sAdmin) ChangePassword(ctx context.Context, req *v1.AdminChangePassword return nil, nil } + +// loadSessionPermissions 装载登录响应所需的有效权限与角色。 +// +// 权限查询失败不阻断登录:鉴权以服务端为准,前端权限集只影响菜单与按钮渲染。 +// 此时返回空集合,用户会看到一个空菜单,刷新页面(走 /auth/me)即可恢复, +// 好过因为一次查询抖动把人挡在登录页外。 +func loadSessionPermissions(ctx context.Context, userID int64, role string) ([]string, []v1.AdminRoleBrief) { + perms, err := GetEffectivePermissions(ctx, userID, role) + if err != nil { + g.Log().Warningf(ctx, "加载管理员 %d 有效权限失败: %v", userID, err) + perms = []string{} + } + if perms == nil { + perms = []string{} + } + + roles := []v1.AdminRoleBrief{} + // 超级管理员不通过角色获得权限,不查关联表 + if role != "super_admin" { + if briefs, err := GetUserRoleBriefs(ctx, userID); err != nil { + g.Log().Warningf(ctx, "加载管理员 %d 角色失败: %v", userID, err) + } else if briefs != nil { + roles = briefs + } + } + return perms, roles +} + +// GetMe 返回当前登录用户的信息与有效权限。 +// +// 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限, +// 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。 +func (s *sAdmin) GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) { + userID := common.GetCtxUserID(ctx) + if userID == 0 { + return nil, common.NewBusinessError(consts.CodeUnauthorized, consts.MsgUnauthorized) + } + + var user *struct { + Id int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, common.NewNotFoundError("用户") + } + + perms, roles := loadSessionPermissions(ctx, user.Id, user.Role) + return &v1.AdminMeRes{ + ID: user.Id, + Username: user.Username, + DisplayName: user.DisplayName, + Email: user.Email, + Role: user.Role, + Permissions: perms, + Roles: roles, + }, nil +} diff --git a/internal/logic/admin/channel.go b/internal/logic/admin/channel.go index 809a674..6d97b4b 100644 --- a/internal/logic/admin/channel.go +++ b/internal/logic/admin/channel.go @@ -55,6 +55,58 @@ func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState { return cat.ChannelRuntimeStates() } +// channelOptionsCache 渠道下拉选项缓存(全量渠道,单个条目)。 +// 日志 / 统计页的渠道筛选每次打开都要拉一次全量渠道,而渠道量级为几十且极少变动, +// 反复对 chn_channels 全表扫描纯属浪费;整表结果缓存为一个条目,写侧主动失效。 +var channelOptionsCache = common.NewCache("channel_options", 300*time.Second) + +// channelOptionsCacheKey 渠道下拉是全量结果,只有一个缓存条目。 +const channelOptionsCacheKey = "all" + +// InvalidateChannelOptionsCache 失效渠道下拉选项缓存(渠道增 / 删 / 改 / 克隆后调用)。 +func InvalidateChannelOptionsCache(ctx context.Context) { + channelOptionsCache.Delete(ctx, channelOptionsCacheKey) +} + +// ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。 +// 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道, +// 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。 +// 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态), +// 下拉场景不要用它 —— 那是本接口存在的意义。 +func (s *sAdmin) ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error) { + list := make([]v1.ChannelOptionItem, 0) + if channelOptionsCache.GetJSON(ctx, channelOptionsCacheKey, &list) { + return &v1.ChannelOptionsRes{List: list}, nil + } + + var rows []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Status string `json:"status"` + } + err := dao.ChnChannels.Ctx(ctx). + Fields("id, name, type, status"). + OrderAsc("id"). + Scan(&rows) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + list = make([]v1.ChannelOptionItem, 0, len(rows)) + for _, r := range rows { + list = append(list, v1.ChannelOptionItem{ + ID: r.ID, + Name: r.Name, + Type: r.Type, + Status: r.Status, + }) + } + + channelOptionsCache.Set(ctx, channelOptionsCacheKey, list) + return &v1.ChannelOptionsRes{List: list}, nil +} + // ListChannels 获取渠道列表 func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) { query := dao.ChnChannels.Ctx(ctx). @@ -277,6 +329,8 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1 g.Log().Warningf(ctx, "init health score for channel %d failed: %v", newID, err) } + InvalidateChannelOptionsCache(ctx) + return &v1.ChannelCloneRes{ID: newID}, nil } @@ -288,10 +342,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 @@ -341,6 +404,8 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (* g.Log().Warningf(ctx, "init health score for channel %d failed: %v", id, err) } + InvalidateChannelOptionsCache(ctx) + return &v1.ChannelCreateRes{ID: id}, nil } @@ -411,8 +476,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 +489,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 } @@ -478,6 +546,7 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (* } } dispatchadapter.InvalidateChannel(ctx, req.ID) + InvalidateChannelOptionsCache(ctx) if req.Status == "active" { // 手动启用/恢复:复位熔断并开启爬坡窗口,恢复初期小流量验证(rampFactor) dispatchadapter.MarkChannelRecovered(ctx, req.ID) @@ -507,6 +576,7 @@ func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (* return nil, err } dispatchadapter.InvalidateChannel(ctx, req.ID) + InvalidateChannelOptionsCache(ctx) return nil, nil } @@ -590,6 +660,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, @@ -719,13 +790,13 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq) // GetChannelAbilities 获取渠道模型能力列表 func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) { var abilities []struct { - ID int64 `json:"id"` - ModelName string `json:"model_name"` - UpstreamModel string `json:"upstream_model"` - Enabled bool `json:"enabled"` - CostRatio float64 `json:"cost_ratio"` - SupportsResponses bool `json:"supports_responses"` - ChatViaResponses bool `json:"chat_via_responses"` + ID int64 `json:"id" orm:"id"` + ModelName string `json:"model_name" orm:"model_name"` + UpstreamModel string `json:"upstream_model" orm:"upstream_model"` + Enabled bool `json:"enabled" orm:"enabled"` + CostRatio float64 `json:"cost_ratio" orm:"cost_ratio"` + SupportsResponses bool `json:"supports_responses" orm:"supports_responses"` + ChatViaResponses bool `json:"chat_via_responses" orm:"chat_via_responses"` } err := dao.ChnAbilities.Ctx(ctx). @@ -914,11 +985,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) @@ -1055,7 +1122,14 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( } return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset 翻页:导出按优先级倒序展示,游标取 (priority, id) 复合键 —— + // priority 大量重复,必须带 id 作 tiebreaker,否则同优先级的行会被跳过或重复。 + // PostgreSQL 行值比较 (a, b) < (?, ?) 按字典序逐列比较,与排序方向一致。 + var ( + hasCursor bool + cursorPriority int + cursorID int64 + ) for { query := dao.ChnChannels.Ctx(ctx). LeftJoin("chn_health_scores h ON chn_channels.id = h.channel_id") @@ -1078,6 +1152,9 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( // 与 ListChannels 保持一致:EXISTS 相关子查询避免 JOIN 重复行 query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%") } + if hasCursor { + query = query.Where("(chn_channels.priority, chn_channels.id) < (?, ?)", cursorPriority, cursorID) + } var batch []struct { ID int64 `json:"id"` Name string `json:"name"` @@ -1088,8 +1165,8 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( CreatedAt *gtime.Time `json:"created_at"` HealthScore *float64 `json:"health_score"` } - if err := query.Fields(channelFields).OrderDesc("chn_channels.priority").Limit(1000).Offset(offset).Scan(&batch); err != nil { - g.Log().Errorf(ctx, "ExportChannels: query batch at offset %d failed: %v", offset, err) + if err := query.Fields(channelFields).OrderDesc("chn_channels.priority").OrderDesc("chn_channels.id").Limit(1000).Scan(&batch); err != nil { + g.Log().Errorf(ctx, "ExportChannels: query batch failed (cursor priority=%d id=%d): %v", cursorPriority, cursorID, err) return } for _, ch := range batch { @@ -1113,11 +1190,12 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) ( }) { return } + cursorPriority, cursorID = ch.Priority, ch.ID } if len(batch) < 1000 { break } - offset += 1000 + hasCursor = true } }) } diff --git a/internal/logic/admin/channel_error.go b/internal/logic/admin/channel_error.go index b1fb755..8a831d3 100644 --- a/internal/logic/admin/channel_error.go +++ b/internal/logic/admin/channel_error.go @@ -9,6 +9,7 @@ import ( "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/common" ) @@ -195,3 +196,18 @@ func (s *sAdmin) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErro }, }, nil } + +// ChannelErrorClear 硬删除全部渠道错误事件。 +// 错误风暴时事件表堆积很快,用 TRUNCATE 而非逐行 DELETE 秒级清空并立即归还磁盘空间。 +// TRUNCATE 不返回行数,故先取删除前条数作为反馈。表无数据库级外键,TRUNCATE 安全。 +// 注意异步写入器(DefaultChannelErrorWriter)清空后继续写入新事件,属预期行为。 +func (s *sAdmin) ChannelErrorClear(ctx context.Context, _ *v1.ChannelErrorClearReq) (*v1.ChannelErrorClearRes, error) { + count, err := dao.ChnErrorEvents.Ctx(ctx).Count() + if err != nil { + return nil, err + } + if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE chn_error_events RESTART IDENTITY"); err != nil { + return nil, err + } + return &v1.ChannelErrorClearRes{Deleted: int64(count)}, nil +} diff --git a/internal/logic/admin/channel_oauth.go b/internal/logic/admin/channel_oauth.go index 78bbcc7..d92e341 100644 --- a/internal/logic/admin/channel_oauth.go +++ b/internal/logic/admin/channel_oauth.go @@ -314,6 +314,9 @@ func createOAuthChannelAndKey(ctx context.Context, oauthData *oauth.OAuthKeyData return 0, 0, err } + // OAuth 建渠道同样要让渠道下拉缓存失效(新建渠道不出现/不失效会滞后一个 TTL) + InvalidateChannelOptionsCache(ctx) + g.Log().Infof(ctx, "[OAuth] 创建 OAuth 渠道: platform=%s, channel_id=%d, key_id=%d, email=%s", platform, channelID, keyID, oauthData.EmailAddress) diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index b7d7742..a781032 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -20,6 +20,27 @@ import ( "github.com/qianfree/team-api/internal/utility/export" ) +// dashboardCache 仪表盘聚合接口的短缓存(L1 内存 + L2 Redis)。 +// 趋势 / 租户排行 / 模型分布 / 小时费用这几个接口都是对 bil_usage_logs +// 的多天窗口聚合扫描,且数据本身不要求秒级新鲜 —— 5 分钟缓存能消除 +// 仪表盘反复打开 / 多管理员同时在线造成的绝大部分重复扫表。 +var dashboardCache = common.NewCache("admin:dashboard", 5*time.Minute) + +// cachedDashboard 按 key 读仪表盘缓存,miss 时回源并写缓存。 +// key 必须编入全部查询参数(如天数 / TopN),避免不同参数共享一份结果。 +func cachedDashboard[T any](ctx context.Context, key string, load func() (*T, error)) (*T, error) { + var cached T + if dashboardCache.GetJSON(ctx, key, &cached) { + return &cached, nil + } + res, err := load() + if err != nil { + return nil, err + } + dashboardCache.Set(ctx, key, res) + return res, nil +} + // GetDashboardStats 获取管理后台仪表盘统计 func (s *sAdmin) GetDashboardStats(ctx context.Context, req *v1.AdminDashboardReq) (*v1.AdminDashboardRes, error) { today := time.Now().Format("2006-01-02") @@ -133,25 +154,27 @@ func (s *sAdmin) GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardT days = 30 } - startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + return cachedDashboard(ctx, fmt.Sprintf("trends:%d", days), func() (*v1.AdminDashboardTrendsRes, error) { + startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") - result, err := g.DB().Ctx(ctx).Raw(` - SELECT - DATE(created_at) as date, - COUNT(*) as requests, - COUNT(DISTINCT tenant_id) as active_tenants, - COALESCE(SUM(total_cost), 0) as revenue - FROM bil_usage_logs - WHERE created_at >= ? - GROUP BY DATE(created_at) - ORDER BY date ASC - `, startDate+" 00:00:00").All() - if err != nil { - return nil, err - } + result, err := g.DB().Ctx(ctx).Raw(` + SELECT + DATE(created_at) as date, + COUNT(*) as requests, + COUNT(DISTINCT tenant_id) as active_tenants, + COALESCE(SUM(total_cost), 0) as revenue + FROM bil_usage_logs + WHERE created_at >= ? + GROUP BY DATE(created_at) + ORDER BY date ASC + `, startDate+" 00:00:00").All() + if err != nil { + return nil, err + } - records := result.List() - return &v1.AdminDashboardTrendsRes{List: records}, nil + records := result.List() + return &v1.AdminDashboardTrendsRes{List: records}, nil + }) } // GetTopTenants returns the top 10 tenants by revenue. @@ -161,28 +184,30 @@ func (s *sAdmin) GetTopTenants(ctx context.Context, req *v1.AdminDashboardTopTen days = 30 } - startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + return cachedDashboard(ctx, fmt.Sprintf("top-tenants:%d", days), func() (*v1.AdminDashboardTopTenantsRes, error) { + startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") - result, err := g.DB().Ctx(ctx).Raw(` - SELECT - t.id as tenant_id, - t.name as tenant_name, - COALESCE(SUM(ul.total_cost), 0) as total_cost, - COUNT(*) as requests, - COUNT(DISTINCT ul.user_id) as active_members - FROM bil_usage_logs ul - JOIN tnt_tenants t ON t.id = ul.tenant_id - WHERE ul.created_at >= ? - GROUP BY t.id, t.name - ORDER BY total_cost DESC - LIMIT 10 - `, startDate+" 00:00:00").All() - if err != nil { - return nil, err - } + result, err := g.DB().Ctx(ctx).Raw(` + SELECT + t.id as tenant_id, + t.name as tenant_name, + COALESCE(SUM(ul.total_cost), 0) as total_cost, + COUNT(*) as requests, + COUNT(DISTINCT ul.user_id) as active_members + FROM bil_usage_logs ul + JOIN tnt_tenants t ON t.id = ul.tenant_id + WHERE ul.created_at >= ? + GROUP BY t.id, t.name + ORDER BY total_cost DESC + LIMIT 10 + `, startDate+" 00:00:00").All() + if err != nil { + return nil, err + } - records := result.List() - return &v1.AdminDashboardTopTenantsRes{List: records}, nil + records := result.List() + return &v1.AdminDashboardTopTenantsRes{List: records}, nil + }) } // GetModelDistribution returns the model usage distribution. @@ -192,27 +217,29 @@ func (s *sAdmin) GetModelDistribution(ctx context.Context, req *v1.AdminDashboar days = 30 } - startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") + return cachedDashboard(ctx, fmt.Sprintf("model-dist:%d", days), func() (*v1.AdminDashboardModelDistributionRes, error) { + startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02") - result, err := g.DB().Ctx(ctx).Raw(` - SELECT - model_name, - COUNT(*) as requests, - COALESCE(SUM(input_tokens), 0) as input_tokens, - COALESCE(SUM(output_tokens), 0) as output_tokens, - COALESCE(SUM(total_cost), 0) as total_cost - FROM bil_usage_logs - WHERE created_at >= ? - GROUP BY model_name - ORDER BY total_cost DESC - LIMIT 20 - `, startDate+" 00:00:00").All() - if err != nil { - return nil, err - } + result, err := g.DB().Ctx(ctx).Raw(` + SELECT + model_name, + COUNT(*) as requests, + COALESCE(SUM(input_tokens), 0) as input_tokens, + COALESCE(SUM(output_tokens), 0) as output_tokens, + COALESCE(SUM(total_cost), 0) as total_cost + FROM bil_usage_logs + WHERE created_at >= ? + GROUP BY model_name + ORDER BY total_cost DESC + LIMIT 20 + `, startDate+" 00:00:00").All() + if err != nil { + return nil, err + } - records := result.List() - return &v1.AdminDashboardModelDistributionRes{List: records}, nil + records := result.List() + return &v1.AdminDashboardModelDistributionRes{List: records}, nil + }) } // GetModelHourlyCost 模型费用按小时堆叠统计,用于仪表盘堆叠柱状图: @@ -227,6 +254,13 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM topN = 8 } + return cachedDashboard(ctx, fmt.Sprintf("model-hourly:%d:%d", hours, topN), func() (*v1.AdminDashboardModelHourlyRes, error) { + return s.queryModelHourlyCost(ctx, hours, topN) + }) +} + +// queryModelHourlyCost 实际执行小时费用聚合(GetModelHourlyCost 的缓存回源路径)。 +func (s *sAdmin) queryModelHourlyCost(ctx context.Context, hours, topN int) (*v1.AdminDashboardModelHourlyRes, error) { // 步骤1:取费用最高的 TopN 个模型名 topResult, err := g.DB().Ctx(ctx).Raw(` SELECT model_name @@ -358,7 +392,10 @@ func validateUsageLogDateRange(start, end string) error { } // buildUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(列表 / 统计 / 导出共用,保证筛选口径一致)。 -// 注意:Username 筛选条件引用 t.username,调用方需按需 JOIN tnt_users(别名 t)。 +// 条件只引用主表别名 u,不依赖任何 JOIN —— 计数与统计路径因此可以完全不联表, +// 只有需要展示名称的列表 / 导出才挂 LEFT JOIN。 +// 用户维度只支持 user_id 精确筛选(前端从下拉选择带出 ID),不提供用户名模糊匹配: +// 前导通配 LIKE 在分区大表的筛选路径上无法走索引,且前端本就没有该查询条件。 func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) { var conditions []string @@ -374,10 +411,6 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) { conditions = append(conditions, "u.user_id = ?") args = append(args, f.UserID) } - if f.Username != "" { - conditions = append(conditions, "t.username LIKE ?") - args = append(args, "%"+f.Username+"%") - } if f.ApiKeyID > 0 { conditions = append(conditions, "u.api_key_id = ?") args = append(args, f.ApiKeyID) @@ -398,6 +431,10 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) { conditions = append(conditions, "u.request_type = ?") args = append(args, f.RequestType) } + if f.UpstreamRequestId != "" { + conditions = append(conditions, "u.upstream_request_id = ?") + args = append(args, f.UpstreamRequestId) + } if f.StartDate != "" { conditions = append(conditions, "u.created_at >= ?") args = append(args, common.StartOfRange(f.StartDate)) @@ -413,6 +450,32 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) { return where, args } +// usageLogJoinedFrom 用量日志展示用联表:租户 / 成员 / 项目 / Key 名称。 +// 四个联表目标都是主键列(1:1,不放大行数),但仍会被分区大表实打实执行 4 次 —— 只给需要名称的查询用。 +const usageLogJoinedFrom = "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id" + +// usageLogListFields 列表展示字段白名单:表格列 + 悬浮明细(Token / 费用分解)所需。 +// 纯关联 ID(user_id / project_id / channel_id)列表不展示(名称经 JOIN 回填, +// tenant_id / api_key_id 保留作名称为空时的兜底展示),只进详情接口; +// 大字段(billing_snapshot、billing_summary、user_agent、error_message 等)同样只进详情, +// 禁止改回全字段 —— 列表每页最多 100 行,快照 JSONB 会成倍放大响应体积。 +const usageLogListFields = `u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, + COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, + u.channel_name, u.model_name, u.upstream_model, u.request_type, u.billing_mode, + u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, + u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, + u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, + u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, + u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.retry_index, u.created_at` + +// usageLogDetailFields 详情字段:列表白名单 + 纯关联 ID + 仅详情展示的大字段(错误信息、UA、计费快照等) +const usageLogDetailFields = usageLogListFields + `, + u.user_id, u.project_id, u.channel_id, + u.channel_type, u.requested_model, u.relay_mode, u.currency, u.billing_source, + u.error_message, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, + u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, + u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.upstream_request_id` + // GetAllUsageLogs 获取所有租户的用量日志(管理后台) func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) { if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { @@ -422,29 +485,25 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR page, pageSize := common.NormalizePagination(req.Page, req.PageSize) where, args := buildUsageLogFilter(req.AdminUsageLogFilter) - fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id" - - countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + where - countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...) + // 计数不展示任何名称,且 buildUsageLogFilter 的条件只引用 u.*, + // 4 个 LEFT JOIN 对计数整体剥离。大范围查询走 bil_usage_daily 日汇总拆段 + // (详见 usage_log_count.go),短窗口仍为明细精确 COUNT。 + total, err := countUsageLogs(ctx, req.AdminUsageLogFilter) if err != nil { return nil, err } - total := 0 - if len(countResult) > 0 { - total = countResult[0]["total"].Int() - } - dataSQL := `SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at - FROM ` + fromClause + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?` + dataSQL := `SELECT ` + usageLogListFields + ` + FROM ` + usageLogJoinedFrom + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?` dataArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize) result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, dataArgs...) if err != nil { return nil, err } - logs := make([]*v1.AdminUsageLogItem, 0, len(result)) + logs := make([]*v1.AdminUsageLogListItem, 0, len(result)) for _, row := range result { - item := &v1.AdminUsageLogItem{} + item := &v1.AdminUsageLogListItem{} if err := row.Struct(item); err != nil { continue } @@ -459,6 +518,30 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR }, nil } +// GetUsageLogDetail 获取单条用量日志详情(含 billing_snapshot / billing_summary / +// user_agent / error_message 等列表不返回的大字段)。 +// bil_usage_logs 按 created_at 月分区且 PK 为 (id, created_at):仅按 id 查询无法 +// 分区裁剪,计划为对各月分区的 Append 索引探测(每分区走本地 PK 前缀命中 id), +// 分区数随月份线性增长但单次探测为微秒级,与 upstream_request_id 反查(000023) +// 同一模式;若日后分区数显著增大,可让调用方携带 created_at 提示做裁剪。 +func (s *sAdmin) GetUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (*v1.AdminUsageLogDetailRes, error) { + dataSQL := `SELECT ` + usageLogDetailFields + ` + FROM ` + usageLogJoinedFrom + ` WHERE u.id = ? LIMIT 1` + result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, req.Id) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, common.NewNotFoundError("用量日志") + } + + item := &v1.AdminUsageLogItem{} + if err := result[0].Struct(item); err != nil { + return nil, err + } + return &v1.AdminUsageLogDetailRes{Data: item}, nil +} + // GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径) func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) { if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { @@ -466,18 +549,14 @@ func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSu } where, args := buildUsageLogFilter(req.AdminUsageLogFilter) - // 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN - fromClause := "bil_usage_logs u" - if req.Username != "" { - fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id" - } - + // 统计聚合不展示名称,筛选条件也只引用 u.*,全程无需联表: + // 大表 SUM 不再背负任何 join。 summarySQL := `SELECT COALESCE(SUM(u.total_cost), 0) AS total_cost, COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens, COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens, COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read - FROM ` + fromClause + where + FROM bil_usage_logs u` + where summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...) if err != nil { @@ -1178,6 +1257,15 @@ func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactio EndDate: req.EndDate, }) + // COUNT 与列表拆开执行:计数不需要租户/用户名称列,筛选条件也不依赖 JOIN + //(Username 过滤在 BuildTransactionQuery 内是 EXISTS 子查询),大表 + // bil_transactions 的计数因此免背 2 个联表。不用 ScanAndCount —— 它的 + // COUNT 会携带模型上的全部 JOIN(gf v2.10.2 源码确认:Clone 保留 join)。 + total, err := query.Count() + if err != nil { + return nil, err + } + type transactionRow struct { Id int64 `json:"id"` TenantId int64 `json:"tenant_id"` @@ -1193,19 +1281,16 @@ func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactio CreatedAt *gtime.Time `json:"created_at"` } - var records []*transactionRow - var total int - err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at"). - LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id"). - LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id"). - OrderDesc("bil_transactions.created_at"). - Page(page, pageSize). - ScanAndCount(&records, &total, false) - if err != nil { - return nil, err - } - if records == nil { - records = make([]*transactionRow, 0) + records := make([]*transactionRow, 0) + if total > 0 { + if err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at"). + LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id"). + LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id"). + OrderDesc("bil_transactions.created_at"). + Page(page, pageSize). + Scan(&records); err != nil { + return nil, err + } } list := make([]*v1.AdminTransactionItem, 0, len(records)) @@ -1278,6 +1363,9 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil { return nil, err } + if err := common.ValidateExportTimeWindow(req.StartDate, req.EndDate, common.UsageLogExportMaxWindowDays); err != nil { + return nil, err + } columns := []export.Column{ {Field: "id", Header: "ID"}, @@ -1289,6 +1377,8 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor {Field: "output_tokens", Header: "输出Token"}, {Field: "total_cost", Header: "费用"}, {Field: "status", Header: "状态"}, + {Field: "request_id", Header: "请求ID"}, + {Field: "upstream_request_id", Header: "上游请求ID"}, {Field: "created_at", Header: "创建时间"}, } @@ -1301,17 +1391,32 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor where, filterArgs := buildUsageLogFilter(req.AdminUsageLogFilter) fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id" - selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at" + selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.request_id, u.upstream_request_id, u.created_at" return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset(游标)翻页替代 OFFSET:OFFSET 每翻一批都要重新扫过并丢弃前面的 + // 所有行,深翻页代价线性上涨;游标条件让每批从上一批断点直接续读。 + // 游标取 id(bigserial 单调递增,PK 索引可直接定位):排序从 created_at DESC + // 改为 id DESC,两者在追加写表上近似同序(同为「新的在前」),却避免了 + // 时间戳做游标时亚秒精度在驱动往返中丢失导致的跳行/重行问题。 + var cursorID int64 for { - sql := "SELECT " + selectFields + " FROM " + fromClause + where + " ORDER BY u.created_at DESC LIMIT ? OFFSET ?" - // 复制一份再追加分页参数,避免污染循环外构建的共用筛选参数 - batchArgs := append(append([]any{}, filterArgs...), 1000, offset) + sql := "SELECT " + selectFields + " FROM " + fromClause + where + batchArgs := append([]any{}, filterArgs...) + if cursorID > 0 { + // 时间窗必填保证 where 非空,这里安全起见仍处理空 where 的情况 + if where == "" { + sql += " WHERE u.id < ?" + } else { + sql += " AND u.id < ?" + } + batchArgs = append(batchArgs, cursorID) + } + sql += " ORDER BY u.id DESC LIMIT ?" + batchArgs = append(batchArgs, 1000) result, err := g.DB().Ctx(ctx).Query(ctx, sql, batchArgs...) if err != nil { - g.Log().Errorf(ctx, "ExportUsageLogs: query batch at offset %d failed: %v", offset, err) + g.Log().Errorf(ctx, "ExportUsageLogs: query batch failed (cursor id=%d): %v", cursorID, err) return } for _, row := range result { @@ -1320,24 +1425,26 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor createdAt = fmt.Sprintf("%v", t.Val()) } if !yield(map[string]any{ - "id": row["id"].Val(), - "tenant_name": row["tenant_name"].Val(), - "username": row["username"].Val(), - "model_name": row["model_name"].Val(), - "request_type": row["request_type"].Val(), - "input_tokens": row["input_tokens"].Val(), - "output_tokens": row["output_tokens"].Val(), - "total_cost": row["total_cost"].Val(), - "status": row["status"].Val(), - "created_at": createdAt, + "id": row["id"].Val(), + "tenant_name": row["tenant_name"].Val(), + "username": row["username"].Val(), + "model_name": row["model_name"].Val(), + "request_type": row["request_type"].Val(), + "input_tokens": row["input_tokens"].Val(), + "output_tokens": row["output_tokens"].Val(), + "total_cost": row["total_cost"].Val(), + "status": row["status"].Val(), + "request_id": row["request_id"].Val(), + "upstream_request_id": row["upstream_request_id"].Val(), + "created_at": createdAt, }) { return } + cursorID = row["id"].Int64() } if len(result) < 1000 { break } - offset += 1000 } }) } @@ -1363,11 +1470,17 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR Columns: columns, } - fetchRecords := func(offset, limit int) ([]map[string]any, error) { + // fetchRecords 取一批记录并返回本批末行 id 作为下一批游标(0 表示无数据) + fetchRecords := func(cursorID int64, limit int) ([]map[string]any, int64, error) { query := dao.BilRecords.Ctx(ctx) if req.TenantID > 0 { query = query.Where("tenant_id", req.TenantID) } + // keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行; + // 排序取 id DESC(追加写表上与 created_at DESC 近似同序) + if cursorID > 0 { + query = query.Where("id < ?", cursorID) + } var records []struct { Id int64 `json:"id"` TenantId int64 `json:"tenant_id"` @@ -1381,8 +1494,8 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR CreatedAt *gtime.Time `json:"created_at"` } if err := query.Fields("id, tenant_id, user_id, channel_id, model_name, input_tokens, output_tokens, total_cost, status, created_at"). - OrderDesc("created_at").Limit(limit).Offset(offset).Scan(&records); err != nil { - return nil, err + OrderDesc("id").Limit(limit).Scan(&records); err != nil { + return nil, 0, err } // Batch resolve names @@ -1433,6 +1546,7 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR } data := make([]map[string]any, len(records)) + var lastID int64 for i, r := range records { data[i] = map[string]any{ "id": r.Id, @@ -1446,16 +1560,17 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR "status": r.Status, "created_at": r.CreatedAt.String(), } + lastID = r.Id } - return data, nil + return data, lastID, nil } return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + var cursorID int64 for { - batch, err := fetchRecords(offset, 1000) + batch, lastID, err := fetchRecords(cursorID, 1000) if err != nil { - g.Log().Errorf(ctx, "ExportBillingRecords: query batch at offset %d failed: %v", offset, err) + g.Log().Errorf(ctx, "ExportBillingRecords: query batch failed (cursor id=%d): %v", cursorID, err) return } for _, row := range batch { @@ -1466,7 +1581,7 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR if len(batch) < 1000 { break } - offset += 1000 + cursorID = lastID } }) } diff --git a/internal/logic/admin/help_center.go b/internal/logic/admin/help_center.go index f940437..3099daf 100644 --- a/internal/logic/admin/help_center.go +++ b/internal/logic/admin/help_center.go @@ -28,6 +28,13 @@ func (s *sAdmin) CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCre return nil, common.NewBusinessError(consts.CodeHelpCategorySlugExists, consts.MsgHelpCategorySlugExists) } + // 父分类校验:必须存在、必须是顶级分类(仅支持两级) + if req.ParentId > 0 { + if err = s.validateHelpCategoryParent(ctx, 0, req.ParentId); err != nil { + return nil, err + } + } + isVisible := true if req.IsVisible != nil { isVisible = *req.IsVisible @@ -77,6 +84,13 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd return nil, common.NewBusinessError(consts.CodeBadRequest, "不能将分类设置为自己的子分类") } + // 父分类校验:必须存在、必须是顶级分类(仅支持两级),且祖先链中不能出现自己(防环导致分类互相锁定无法删除) + if req.ParentId > 0 { + if err = s.validateHelpCategoryParent(ctx, req.Id, req.ParentId); err != nil { + return nil, err + } + } + updateData := do.SptCategories{ ParentId: req.ParentId, Name: req.Name, @@ -100,6 +114,45 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd return &v1.HelpCategoryUpdateRes{}, nil } +// validateHelpCategoryParent 校验父分类:必须存在、必须是顶级分类(仅支持两级结构), +// 且父分类的祖先链中不能出现 selfId(否则会形成 A→B→A 的环,环上分类因互为父子被删除校验拦截,永远无法删除) +func (s *sAdmin) validateHelpCategoryParent(ctx context.Context, selfId, parentId int64) error { + var parent *struct { + Id int64 `json:"id"` + ParentId int64 `json:"parent_id"` + } + err := dao.SptCategories.Ctx(ctx).Where("id", parentId).Scan(&parent) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + if parent == nil { + return common.NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound) + } + if parent.ParentId != 0 { + return common.NewBusinessError(consts.CodeBadRequest, "仅支持两级分类,父分类必须是顶级分类") + } + + // 沿祖先链向上检查是否会出现自己(数据异常时用深度上限兜底) + current := parent.ParentId + for i := 0; i < 50 && current != 0; i++ { + if current == selfId { + return common.NewBusinessError(consts.CodeBadRequest, "不能将分类挂到自身或其子分类下") + } + var ancestor *struct { + ParentId int64 `json:"parent_id"` + } + err = dao.SptCategories.Ctx(ctx).Where("id", current).Scan(&ancestor) + if err = common.IgnoreScanNoRows(err); err != nil { + return err + } + if ancestor == nil { + return nil + } + current = ancestor.ParentId + } + return nil +} + // DeleteHelpCategory 删除帮助分类 func (s *sAdmin) DeleteHelpCategory(ctx context.Context, req *v1.HelpCategoryDeleteReq) (*v1.HelpCategoryDeleteRes, error) { var cat *struct { @@ -223,9 +276,10 @@ func (s *sAdmin) CreateHelpArticle(ctx context.Context, req *v1.HelpArticleCreat // UpdateHelpArticle 更新帮助文章 func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdateReq) (*v1.HelpArticleUpdateRes, error) { var article *struct { - Id int64 `json:"id"` - CategoryId int64 `json:"category_id"` - Status string `json:"status"` + Id int64 `json:"id"` + CategoryId int64 `json:"category_id"` + Status string `json:"status"` + PublishedAt *gtime.Time `json:"published_at"` } err := dao.SptArticles.Ctx(ctx).Where("id", req.Id).Scan(&article) if err = common.IgnoreScanNoRows(err); err != nil { @@ -269,8 +323,8 @@ func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdat data.Content = req.Content } - // 从草稿发布时设置发布时间 - if req.Status == "published" && article.Status != "published" { + // 从草稿发布时设置发布时间(仅首次发布固定;下架再上架不重置,保持原始发布时间与排序位置) + if req.Status == "published" && article.Status != "published" && article.PublishedAt == nil { data.PublishedAt = gtime.Now() } diff --git a/internal/logic/admin/member.go b/internal/logic/admin/member.go index 63eca45..8983148 100644 --- a/internal/logic/admin/member.go +++ b/internal/logic/admin/member.go @@ -130,17 +130,15 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq) func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) (*v1.AdminMemberListRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) - m := dao.TntUsers.Ctx(ctx). - LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id") - m = buildMemberFilters(m, req.Keyword, req.Status, req.Role, req.TenantID) - - total, err := m.Count() + // COUNT 不需要租户名称列,且 buildMemberFilters 的条件只引用 tnt_users.* —— + // 计数免联表(联表目标是 tnt_tenants 主键,1:1 不影响行数) + total, err := buildMemberFilters(dao.TntUsers.Ctx(ctx), req.Keyword, req.Status, req.Role, req.TenantID).Count() if err != nil { return nil, err } - // Rebuild model for data query (Count() modifies internal state) - m = dao.TntUsers.Ctx(ctx). + // 列表查询才需要 JOIN 补租户名称/代码 + m := dao.TntUsers.Ctx(ctx). LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id") m = buildMemberFilters(m, req.Keyword, req.Status, req.Role, req.TenantID) @@ -393,12 +391,16 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq } return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行(排序本就是 id DESC) + var cursorID int64 for { m := buildMemberFilters( dao.TntUsers.Ctx(ctx).LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id"), req.Keyword, req.Status, req.Role, req.TenantID, ) + if cursorID > 0 { + m = m.Where("tnt_users.id < ?", cursorID) + } var batch []struct { Id int64 `json:"id"` TenantName string `json:"tenant_name"` @@ -410,7 +412,7 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq LastLoginAt *gtime.Time `json:"last_login_at"` CreatedAt *gtime.Time `json:"created_at"` } - if err := m.Fields(memberFields).OrderDesc("tnt_users.id").Limit(1000).Offset(offset).Scan(&batch); err != nil { + if err := m.Fields(memberFields).OrderDesc("tnt_users.id").Limit(1000).Scan(&batch); err != nil { return } for _, m := range batch { @@ -427,11 +429,11 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq }) { return } + cursorID = m.Id } if len(batch) < 1000 { break } - offset += 1000 } }) } diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index 6f1e022..e0ea182 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -3,8 +3,10 @@ package admin import ( "context" "encoding/json" + "fmt" "math" "strings" + "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" @@ -23,40 +25,67 @@ import ( "github.com/qianfree/team-api/internal/utility/export" ) +// modelOptionsCache 模型下拉选项缓存(全量模型,单个条目)。 +// 下拉接口是若干管理页每次打开都要调的,而模型表极少变动; +// 整表结果缓存为一个条目、按 status/category 在内存里过滤,写侧主动失效。 +var modelOptionsCache = common.NewCache("model_options", 300*time.Second) + +// modelOptionsCacheKey 模型下拉是全量结果,只有一个缓存条目。 +const modelOptionsCacheKey = "all" + +// InvalidateModelOptionsCache 失效模型下拉选项缓存(模型增 / 删 / 改后调用)。 +// 注:task 包的模型下线定时任务改 status 时不经此处,下拉最多滞后一个 TTL(300s); +// 对「按状态筛模型」的下拉而言可接受,故不为此在 task 包反向依赖 admin。 +func InvalidateModelOptionsCache(ctx context.Context) { + modelOptionsCache.Delete(ctx, modelOptionsCacheKey) +} + +// modelOptionRow 下拉缓存行:比出参多一个 status,供内存过滤。 +// 缓存存全量(不过滤),避免 status / category 的每个组合各占一个缓存条目。 +type modelOptionRow struct { + ID int64 `json:"id"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Status string `json:"status"` +} + // ListModelOptions 获取模型选项列表(不分页,用于下拉选择) func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) { - query := dao.MdlModels.Ctx(ctx) - - if req.Status != "" { - query = query.Where("status", req.Status) - } else { - query = query.Where("status", "active") - } - if req.Category != "" { - query = query.Where("category", req.Category) + rows := make([]modelOptionRow, 0) + if !modelOptionsCache.GetJSON(ctx, modelOptionsCacheKey, &rows) { + err := dao.MdlModels.Ctx(ctx). + Fields("id, model_id, model_name, category, status"). + OrderAsc("category").OrderAsc("model_id"). + Scan(&rows) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if rows == nil { + rows = make([]modelOptionRow, 0) + } + modelOptionsCache.Set(ctx, modelOptionsCacheKey, rows) } - var models []struct { - ID int64 `json:"id"` - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` + // 沿用原接口语义:不传 status 即只要可用模型 + status := req.Status + if status == "" { + status = "active" } - err := query.Fields("id, model_id, model_name, category"). - OrderAsc("category").OrderAsc("model_id"). - Scan(&models) - if err = common.IgnoreScanNoRows(err); err != nil { - return nil, err - } - - list := make([]v1.ModelOptionItem, 0, len(models)) - for _, m := range models { + list := make([]v1.ModelOptionItem, 0, len(rows)) + for _, r := range rows { + if r.Status != status { + continue + } + if req.Category != "" && r.Category != req.Category { + continue + } list = append(list, v1.ModelOptionItem{ - ID: m.ID, - ModelId: m.ModelId, - ModelName: m.ModelName, - Category: m.Category, + ID: r.ID, + ModelId: r.ModelId, + ModelName: r.ModelName, + Category: r.Category, }) } @@ -70,19 +99,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode if req.Category != "" { query = query.Where("category", req.Category) } + if req.Vendor != "" { + query = query.Where("vendor", req.Vendor) + } if req.Status != "" { query = query.Where("status", req.Status) } if req.Search != "" { query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") } - // 定价状态筛选 + // 定价状态筛选(pricing JSONB 唯一真相:任一模式有正价即已定价) + // 已配价谓词:token 价>0 / per_request 价>0 / per_second 矩阵非空 / tiered 任一档价>0 + const pricedPredicate = `EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND ( + (pricing->>'input_price')::numeric > 0 + OR (pricing->>'output_price')::numeric > 0 + OR (pricing->>'price')::numeric > 0 + OR (pricing->'prices' IS NOT NULL AND pricing->'prices' <> '{}'::jsonb) + OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(pricing->'tiers', '[]'::jsonb)) tx WHERE (tx->>'input_price')::numeric > 0 OR (tx->>'output_price')::numeric > 0) + ))` if req.PricingStatus == "priced" { - // 已定价:存在 min_tokens=0 的记录,且输入价格或输出价格不为 0 - query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND (mdl_pricing.input_price > 0 OR mdl_pricing.output_price > 0 OR mdl_pricing.per_request_price > 0))") + query = query.Where(pricedPredicate) } else if req.PricingStatus == "unpriced" { - // 未定价:存在 min_tokens=0 的记录,但所有价格字段都为 0 - query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND mdl_pricing.input_price = 0 AND mdl_pricing.output_price = 0 AND (mdl_pricing.per_request_price IS NULL OR mdl_pricing.per_request_price = 0))") + query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id) AND NOT " + pricedPredicate) } var total int @@ -91,6 +129,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` Status string `json:"status"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` @@ -104,7 +143,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ReplacementModel string `json:"replacement_model"` } - err := query.Fields("id, model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model"). + err := query.Fields("id, model_id, model_name, category, vendor, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model"). OrderDesc("id"). Page(req.Page, req.PageSize). ScanAndCount(&models, &total, false) @@ -118,17 +157,15 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode modelIDs = append(modelIDs, m.ID) } type pricingRow struct { - ModelId int64 `json:"model_id"` - BillingMode string `json:"billing_mode"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` + ModelId int64 `json:"model_id"` + BillingMode string `json:"billing_mode"` + Pricing string `json:"pricing"` } var pricingRows []pricingRow if len(modelIDs) > 0 { err = dao.MdlPricing.Ctx(ctx). - Fields("model_id, billing_mode, input_price, output_price, per_request_price"). - Where("model_id IN (?) AND min_tokens = 0", modelIDs). + Fields("model_id, billing_mode, pricing"). + Where("model_id IN (?)", modelIDs). Scan(&pricingRows) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err @@ -179,6 +216,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, Status: m.Status, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, @@ -197,13 +235,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode item.SunsetDate = &s } item.ReplacementModel = m.ReplacementModel - // 填充定价摘要 + // 填充定价摘要(从 pricing JSONB 解析;tiered 取首档价,与旧列表口径一致) if p, ok := pricingMap[m.ID]; ok { item.PricingMode = p.BillingMode - item.InputPrice = p.InputPrice - item.OutputPrice = p.OutputPrice - if p.PerRequestPrice != nil { - item.PerRequestPrice = *p.PerRequestPrice + if blob := billing.ParsePricingBlob(p.Pricing); blob != nil { + switch { + case len(blob.Tiers) > 0: + item.InputPrice = blob.Tiers[0].InputPrice + item.OutputPrice = blob.Tiers[0].OutputPrice + default: + if blob.InputPrice != nil { + item.InputPrice = *blob.InputPrice + } + if blob.OutputPrice != nil { + item.OutputPrice = *blob.OutputPrice + } + if blob.Price != nil { + item.PerRequestPrice = *blob.Price + } + if len(blob.Prices) > 0 { + item.PerSecondPrices = blob.Prices + } + } } } // 填充可用渠道 @@ -235,6 +288,7 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M ModelId: req.ModelId, ModelName: req.ModelName, Category: req.Category, + Vendor: req.Vendor, // 空串=未分类 Status: "active", MaxContextTokens: req.MaxContext, MaxOutputTokens: req.MaxOutput, @@ -255,21 +309,37 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M return nil, err } + // 默认 token 模式空定价行(未配价,SetModelPricing 时覆盖 pricing JSONB) _, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ ModelId: id, BillingMode: "token", - MinTokens: 0, - MaxTokens: nil, - InputPrice: 0, - OutputPrice: 0, + Pricing: "{}", }) if err != nil { return nil, err } + InvalidateModelOptionsCache(ctx) + return &v1.ModelCreateRes{ID: id}, nil } +// validModelVendors 模型研发厂商合法值集合(与 mdl_models.vendor 枚举、API v:"in:" 标签、前端字典对齐)。 +// 注意与 chn_channels.type 的"渠道供应商"是两个维度:研发厂商指模型的开发公司, +// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。 +var validModelVendors = map[string]struct{}{ + "openai": {}, "anthropic": {}, "google": {}, "xai": {}, "mistral": {}, "cohere": {}, + "meta": {}, "alibaba": {}, "bytedance": {}, "deepseek": {}, "zhipu": {}, "moonshot": {}, + "minimax": {}, "baidu": {}, "tencent": {}, "xunfei": {}, "kuaishou": {}, "midjourney": {}, + "suno": {}, +} + +// isValidModelVendor 校验研发厂商枚举值 +func isValidModelVendor(v string) bool { + _, ok := validModelVendors[v] + return ok +} + // UpdateModel 更新模型(含弃用状态管理) func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) { var oldModel *struct { @@ -293,6 +363,14 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M data.Category = req.Category hasUpdate = true } + // 厂商指针语义:nil=不更新;空串=清空回未分类(do 字段为 any,空串不会被 ORM 忽略);非空=校验枚举后设置 + if req.Vendor != nil { + if *req.Vendor != "" && !isValidModelVendor(*req.Vendor) { + return nil, common.NewBusinessError(422, "厂商值不合法") + } + data.Vendor = *req.Vendor + hasUpdate = true + } data.MaxContextTokens = req.MaxContext data.MaxOutputTokens = req.MaxOutput hasUpdate = true @@ -349,6 +427,10 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M return nil, err } + // 下拉缓存按全量存、内存过滤 status/category,故改名 / 改分类同样要失效, + // 不能只在 statusChanged 时清。 + InvalidateModelOptionsCache(ctx) + if statusChanged { // 状态变更时清除模型缓存和租户分组缓存 relay.NewDataProvider().InvalidateModelCache(oldModel.ModelId) @@ -425,6 +507,7 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M if model.ModelId != "" { billing.ClearModelPriceCache(ctx, model.ModelId) } + InvalidateModelOptionsCache(ctx) return nil, nil } @@ -440,68 +523,126 @@ func parseCapabilities(raw string) map[string]bool { return caps } -// GetModelPricing 获取模型定价 +// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容) func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) { - var rows []struct { - BillingMode string `json:"billing_mode"` - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - CacheReadPrice float64 `json:"cache_read_price"` - CacheCreationPrice float64 `json:"cache_creation_price"` - TimeSegments string `json:"time_segments"` - PriceNote *string `json:"price_note"` - DiscountLabel *string `json:"discount_label"` - PriceChangeNote *string `json:"price_change_note"` + var row *struct { + BillingMode string `json:"billing_mode" orm:"billing_mode"` + Pricing string `json:"pricing" orm:"pricing"` + OfficialPricing string `json:"official_pricing" orm:"official_pricing"` + PriceNote *string `json:"price_note" orm:"price_note"` + DiscountLabel *string `json:"discount_label" orm:"discount_label"` + PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"` } err := dao.MdlPricing.Ctx(ctx). Where("model_id", req.ModelID). - OrderAsc("min_tokens"). - Scan(&rows) + Scan(&row) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } - result := make([]v1.PricingItem, 0, len(rows)) - var timeSegments []billing.TimeSegment + var result []v1.PricingItem + var timeSegments []v1.TimeSegmentItem + var paramRules []v1.ParamMultiplierItem + var officialItems []v1.PricingItem + var officialTimeSegments []v1.TimeSegmentItem + var officialParamRules []v1.ParamMultiplierItem var priceNote, discountLabel, priceChangeNote string - for i := range rows { - r := rows[i] - result = append(result, v1.PricingItem{ - BillingMode: r.BillingMode, - MinTokens: r.MinTokens, - MaxTokens: r.MaxTokens, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - PerRequestPrice: r.PerRequestPrice, - CacheReadPrice: r.CacheReadPrice, - CacheCreationPrice: r.CacheCreationPrice, - }) - // 时段定价与展示字段只认锚点行(min_tokens=0),阶梯行上的残留忽略 - if r.MinTokens == 0 { - if r.TimeSegments != "" && r.TimeSegments != "null" { - if err := json.Unmarshal([]byte(r.TimeSegments), &timeSegments); err != nil { - g.Log().Warningf(ctx, "admin: 模型 %d 时段定价解析失败: %v", req.ModelID, err) - timeSegments = nil - } - } - priceNote = derefStr(r.PriceNote) - discountLabel = derefStr(r.DiscountLabel) - priceChangeNote = derefStr(r.PriceChangeNote) + var scheme string + var schemeConfig json.RawMessage + if row != nil { + // 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑) + blob := billing.ParsePricingBlob(row.Pricing) + result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, blob) + if blob != nil { + scheme = blob.Scheme + schemeConfig = blob.SchemeConfig } + + // 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil + offMode, offBlob := officialBlobFromJSON(row.OfficialPricing) + if offBlob != nil { + officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob) + } + + priceNote = derefStr(row.PriceNote) + discountLabel = derefStr(row.DiscountLabel) + priceChangeNote = derefStr(row.PriceChangeNote) } return &v1.PricingGetRes{ - List: result, - TimeSegments: timeSegmentsToAPI(timeSegments), - PriceNote: priceNote, - DiscountLabel: discountLabel, - PriceChangeNote: priceChangeNote, + List: result, + TimeSegments: timeSegments, + ParamMultipliers: paramRules, + OfficialItems: officialItems, + OfficialTimeSegments: officialTimeSegments, + OfficialParamMultipliers: officialParamRules, + PriceNote: priceNote, + DiscountLabel: discountLabel, + PriceChangeNote: priceChangeNote, + Scheme: scheme, + SchemeConfig: schemeConfig, }, nil } +// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。 +// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时 +// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。 +func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) { + if blob == nil { + blob = &billing.PricingBlob{} + } + items := make([]v1.PricingItem, 0, 1) + switch { + case len(blob.Tiers) > 0: + // tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。 + // 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容) + for i, tier := range blob.Tiers { + item := v1.PricingItem{ + BillingMode: mode, + MinTokens: tier.MinTokens, + MaxTokens: tier.MaxTokens, + InputPrice: tier.InputPrice, + OutputPrice: tier.OutputPrice, + } + if tier.CacheReadPrice != nil { + item.CacheReadPrice = *tier.CacheReadPrice + } else if i == 0 && blob.CacheReadPrice != nil { + item.CacheReadPrice = *blob.CacheReadPrice + } + if tier.CacheCreationPrice != nil { + item.CacheCreationPrice = *tier.CacheCreationPrice + } else if i == 0 && blob.CacheCreationPrice != nil { + item.CacheCreationPrice = *blob.CacheCreationPrice + } + items = append(items, item) + } + default: + item := v1.PricingItem{BillingMode: mode} + if blob.InputPrice != nil { + item.InputPrice = *blob.InputPrice + } + if blob.OutputPrice != nil { + item.OutputPrice = *blob.OutputPrice + } + if blob.CacheReadPrice != nil { + item.CacheReadPrice = *blob.CacheReadPrice + } + if blob.CacheCreationPrice != nil { + item.CacheCreationPrice = *blob.CacheCreationPrice + } + if blob.Price != nil { + p := *blob.Price + item.PerRequestPrice = &p + } + if len(blob.Prices) > 0 { + item.PerSecondPrices = blob.Prices + } + items = append(items, item) + } + // 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原 + return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers) +} + // derefStr 字符串指针安全解引用(NULL → 空串) func derefStr(s *string) string { if s == nil { @@ -553,17 +694,222 @@ func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, err return result, nil } -// writeTimeSegmentsForModel 把时段定价写到模型定价锚点行(min_tokens=0),空列表=清除(全量替换语义)。 -// 定价保存与模型导入共用。清除时写 "[]" 而非 SQL NULL:避免依赖 ORM 对 nil 值 UPDATE 的默认行为 -// (被省略会让「关闭时段」静默失效),所有读取方(计费/租户/管理端)均已把空数组视为未配置。 -// 用列名直写而非 do 字段:do.MdlPricing 的 TimeSegments 字段需迁移落库后 gf gen dao 重新生成才存在, -// 直写列名与生成物解耦(wire 格式与 custom_pricing_tiers JSONB 先例一致)。调用方需保证事务 ctx 传播。 +// paramMultipliersFromAPI API 参数倍率规则 → billing 规则(含校验) +func paramMultipliersFromAPI(items []v1.ParamMultiplierItem) ([]billing.ParamRule, error) { + if len(items) == 0 { + return nil, nil + } + rules := make([]billing.ParamRule, 0, len(items)) + for _, item := range items { + rule := billing.ParamRule{ + Multiplier: item.Multiplier, + Note: strings.TrimSpace(item.Note), + } + for _, c := range item.Conditions { + rule.Conditions = append(rule.Conditions, billing.ParamCondition{ + Path: strings.TrimSpace(c.Path), + Match: c.Match, + Value: c.Value, + }) + } + rules = append(rules, rule) + } + if err := billing.ValidateParamMultipliers(rules); err != nil { + return nil, err + } + return rules, nil +} + +// paramMultipliersToAPI billing 规则 → API 参数倍率项 +func paramMultipliersToAPI(rules []billing.ParamRule) []v1.ParamMultiplierItem { + if len(rules) == 0 { + return nil + } + items := make([]v1.ParamMultiplierItem, 0, len(rules)) + for _, rule := range rules { + item := v1.ParamMultiplierItem{ + Multiplier: rule.Multiplier, + Note: rule.Note, + } + for _, c := range rule.Conditions { + item.Conditions = append(item.Conditions, v1.ParamConditionItem{ + Path: c.Path, + Match: c.Match, + Value: c.Value, + }) + } + items = append(items, item) + } + return items +} + +// pricingItemsToInput API 定价项 → billing 构造输入 +func pricingItemsToInput(items []v1.PricingItem) []billing.PricingItemInput { + result := make([]billing.PricingItemInput, 0, len(items)) + for _, item := range items { + result = append(result, billing.PricingItemInput{ + BillingMode: item.BillingMode, + MinTokens: item.MinTokens, + MaxTokens: item.MaxTokens, + InputPrice: item.InputPrice, + OutputPrice: item.OutputPrice, + PerRequestPrice: item.PerRequestPrice, + CacheReadPrice: item.CacheReadPrice, + CacheCreationPrice: item.CacheCreationPrice, + PerSecondPrices: item.PerSecondPrices, + }) + } + return result +} + +// writePricingForModel 全量替换模型定价:单行写 billing_mode + pricing JSONB(每模型一行, +// uk_mdl_pricing_model 唯一)。SetModelPricing 与模型导入共用;时段定价随后经 +// writeTimeSegmentsForModel 的 jsonb_set 并入 JSON 顶层;参数倍率规则随 blob 顶层写入。 +// scheme/schemeConfig 为计费方案声明与私有配置:导入路径透传导出文件中的方案字段 +// (旧版导出文件无该字段=空串/nil,导入后为通用引擎定价);两条路径均已在调用前 +// 完成 special ⇔ scheme 配对与 SchemeRegistered 校验。 +// 调用方需保证事务 ctx 传播。 +func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule, scheme string, schemeConfig json.RawMessage) error { + blob, err := billing.BuildPricingBlob(pricingItemsToInput(items)) + if err != nil { + return err + } + blob.ParamMultipliers = paramRules + blob.Scheme = scheme + blob.SchemeConfig = schemeConfig + blobJSON, err := json.Marshal(blob) + if err != nil { + return err + } + + // 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing, + // official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工 + // 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。 + // 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。 + if _, err := dao.MdlPricing.Ctx(ctx). + Data(g.Map{ + "model_id": modelDBID, + "billing_mode": items[0].BillingMode, + "pricing": string(blobJSON), + }). + OnConflict("model_id"). + OnDuplicate(g.Map{ + "billing_mode": gdb.Raw("excluded.billing_mode"), + "pricing": gdb.Raw("excluded.pricing"), + }). + Save(); err != nil { + return err + } + return nil +} + +// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺 +// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构, +// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。 +type officialPricingBlob struct { + BillingMode string `json:"billing_mode"` + billing.PricingBlob +} + +// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。 +// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。 +// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」 +// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存), +// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。 +func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) { + segs, err := timeSegmentsFromAPI(segments) + if err != nil { + return nil, err + } + paramRules, err := paramMultipliersFromAPI(rules) + if err != nil { + return nil, err + } + input := pricingItemsToInput(items) + var blob *billing.PricingBlob + if len(input) > 0 && (input[0].BillingMode == "per_second" || input[0].BillingMode == billing.BillingModeSpecial) && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) { + blob = &billing.PricingBlob{} + } else { + blob, err = billing.BuildPricingBlob(input) + if err != nil { + return nil, err + } + } + blob.TimeSegments = segs + blob.ParamMultipliers = paramRules + empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil && + blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil && + len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0 + if empty { + return nil, nil + } + return blob, nil +} + +// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态) +func perSecondMatrixHasPositivePrice(prices map[string]float64) bool { + for _, p := range prices { + if p > 0 { + return true + } + } + return false +} + +// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。 +// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。 +func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) { + if raw == "" || raw == "null" { + return "", nil + } + wrapped := officialPricingBlob{} + if err := json.Unmarshal([]byte(raw), &wrapped); err != nil { + return "", nil + } + mode := wrapped.BillingMode + if mode == "" { + mode = "token" + } + return mode, &wrapped.PricingBlob +} + +// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换, +// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层, +// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦 +// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。 +func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error { + blob, err := buildOfficialBlob(items, segments, rules) + if err != nil { + return err + } + data := any(gdb.Raw("NULL")) + if blob != nil { + mode := "token" + if len(items) > 0 { + mode = items[0].BillingMode + } + raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob}) + if mErr != nil { + return mErr + } + data = string(raw) + } + _, err = dao.MdlPricing.Ctx(ctx). + Where("model_id", modelDBID). + Data(g.Map{"official_pricing": data}). + Update() + return err +} + +// writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键), +// 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。 +// 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。 +// 单引号需 SQL 转义(json.Marshal 不转义内容中的单引号)。调用方需保证事务 ctx 传播。 func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.TimeSegmentItem) error { segs, err := timeSegmentsFromAPI(items) if err != nil { return err } - // 空配置显式写 "[]"(nil 切片会序列化成 "null",语义不明确) segmentsJSON := "[]" if len(segs) > 0 { data, mErr := json.Marshal(segs) @@ -572,15 +918,18 @@ func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1. } segmentsJSON = string(data) } + escapedJSON := strings.ReplaceAll(segmentsJSON, "'", "''") _, err = dao.MdlPricing.Ctx(ctx). Where("model_id", modelDBID). - Where("min_tokens", 0). - Data(g.Map{"time_segments": segmentsJSON}). + Data(g.Map{ + "pricing": gdb.Raw(fmt.Sprintf( + `jsonb_set(COALESCE(pricing, '{}'::jsonb), '{time_segments}', '%s'::jsonb)`, escapedJSON)), + }). Update() return err } -// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到定价锚点行(min_tokens=0)。 +// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到模型定价行。 // 全量替换语义:空串=清除(写 NULL)。与 writeTimeSegmentsForModel 同理用列名直写而非 do 字段, // 与 gf gen dao 生成物解耦。调用方需保证事务 ctx 传播。 func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, priceNote, discountLabel, priceChangeNote string) error { @@ -593,7 +942,6 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri } _, err := dao.MdlPricing.Ctx(ctx). Where("model_id", modelDBID). - Where("min_tokens", 0). Data(g.Map{ "price_note": trimOrNull(priceNote), "discount_label": trimOrNull(discountLabel), @@ -606,22 +954,46 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri // SetModelPricing 设置模型定价(全量替换) func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) { // 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误 - segments, err := timeSegmentsFromAPI(req.TimeSegments) + if _, err := timeSegmentsFromAPI(req.TimeSegments); err != nil { + return nil, err + } + + // 参数倍率规则校验(路径/匹配方式/值形态/倍率区间),fail-fast + paramRules, err := paramMultipliersFromAPI(req.ParamMultipliers) if err != nil { return nil, err } - // 时段配置挂在锚点行(min_tokens=0)上,列表中必须存在锚点行 - if len(segments) > 0 { - hasAnchor := false - for _, item := range req.Items { - if item.MinTokens == 0 { - hasAnchor = true - break - } + // 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价) + if req.OfficialItems != nil { + if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") } - if !hasAnchor { - return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行") + if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") + } + } + + // 计费方案校验:未注册的方案名不得写入(运行期引擎无从得知正确价格); + // scheme_config 由方案实现自校验(generic 不接受非空配置,拦截「空方案带配置」的错位数据) + req.Scheme = strings.TrimSpace(req.Scheme) + if req.Scheme != "" && !billing.SchemeRegistered(req.Scheme) { + return nil, gerror.Newf("计费方案 %q 未注册", req.Scheme) + } + if err := billing.LookupScheme(req.Scheme).ValidateSchemeConfig(req.SchemeConfig); err != nil { + return nil, err + } + + // billing_mode 与 scheme 双向配对(special ⇔ 方案声明):裸 special 会落 generic + // 分发走 default 分支(错价口径不可预期),方案挂非 special 模式会造成展示标签 + // 与计费口径错位(如显示「按秒」实为素材组合计费),两者都在保存时拦截 + if len(req.Items) > 0 { + isSpecial := req.Items[0].BillingMode == billing.BillingModeSpecial + if isSpecial && req.Scheme == "" { + return nil, gerror.New("特殊计费模式(special)必须声明计费方案(scheme)") + } + if !isSpecial && req.Scheme != "" { + return nil, gerror.New("声明计费方案的模型必须使用特殊计费模式(billing_mode=special)") } } @@ -635,42 +1007,28 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v } } - // 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败, - // 该模型会残留「无定价」状态,导致计费失败或回退到默认价。 + // 全量替换:单行写 pricing JSONB(计费详情 + 参数倍率随 blob 顶层),同一事务。 + // 否则中途失败会残留「无定价」状态,导致计费失败或回退到默认价。 err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete(); err != nil { + if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules, req.Scheme, req.SchemeConfig); err != nil { return err } - for _, item := range req.Items { - // API 边界 float64 → DO decimal 转换 - var perRequestPriceDecimal *decimal.Decimal - if item.PerRequestPrice != nil { - d := billing.NewFromFloat(*item.PerRequestPrice) - perRequestPriceDecimal = &d - } - - if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ - ModelId: req.ModelID, - BillingMode: item.BillingMode, - MinTokens: item.MinTokens, - MaxTokens: item.MaxTokens, - InputPrice: billing.NewFromFloat(item.InputPrice), - OutputPrice: billing.NewFromFloat(item.OutputPrice), - PerRequestPrice: perRequestPriceDecimal, - CacheReadPrice: billing.NewFromFloat(item.CacheReadPrice), - CacheCreationPrice: billing.NewFromFloat(item.CacheCreationPrice), - }); err != nil { - return err + // 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。 + // 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据, + // 不触发价格缓存失效 + if req.OfficialItems != nil { + if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil { + return gerror.Wrap(err, "官方参考定价") } } - // 时段定价写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行) + // 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行) if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil { return err } - // 展示字段写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行) + // 展示字段写定价行(空=清除,全量替换语义;需在定价行插入之后执行) if err := writePricingDisplayFieldsForModel(ctx, req.ModelID, req.PriceNote, req.DiscountLabel, req.PriceChangeNote); err != nil { return err } diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 0f95085..3c10d03 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -6,13 +6,13 @@ import ( "encoding/json" "io" "net/url" + "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" - "github.com/shopspring/decimal" "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/consts" @@ -29,6 +29,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe ModelId string `orm:"model_id" json:"model_id"` ModelName string `orm:"model_name" json:"model_name"` Category string `orm:"category" json:"category"` + Vendor string `orm:"vendor" json:"vendor"` Status string `orm:"status" json:"status"` MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"` MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"` @@ -50,30 +51,37 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe } type exportPricing struct { - BillingMode string `json:"billing_mode"` - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - CacheReadPrice float64 `json:"cache_read_price"` - CacheCreationPrice float64 `json:"cache_creation_price"` + BillingMode string `json:"billing_mode"` + MinTokens int64 `json:"min_tokens"` + MaxTokens *int64 `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + CacheReadPrice float64 `json:"cache_read_price"` + CacheCreationPrice float64 `json:"cache_creation_price"` + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 仅 per_second 模式(锚点行 pricing JSONB) } type exportModel struct { - ModelId string `json:"model_id"` - ModelName string `json:"model_name"` - Category string `json:"category"` - Status string `json:"status"` - MaxContextTokens int `json:"max_context_tokens"` - MaxOutputTokens int `json:"max_output_tokens"` - Description string `json:"description"` - Tags []string `json:"tags"` - Capabilities map[string]bool `json:"capabilities"` - SunsetDate string `json:"sunset_date,omitempty"` - ReplacementModel string `json:"replacement_model,omitempty"` - Pricing []exportPricing `json:"pricing"` - TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"` + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Vendor string `json:"vendor,omitempty"` // 研发厂商(空=未分类,旧文件导入兼容) + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Description string `json:"description"` + Tags []string `json:"tags"` + Capabilities map[string]bool `json:"capabilities"` + SunsetDate string `json:"sunset_date,omitempty"` + ReplacementModel string `json:"replacement_model,omitempty"` + Pricing []exportPricing `json:"pricing"` + TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"` + ParamMultipliers []v1.ParamMultiplierItem `json:"param_multipliers,omitempty"` + // 特殊计费方案声明(pricing JSONB 顶层 scheme/scheme_config,模型级语义): + // 随导出携带,导入侧透传还原,避免跨环境迁移后方案定价退化为通用模式 + Scheme string `json:"scheme,omitempty"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` } // 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。 @@ -82,16 +90,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe modelIDs = append(modelIDs, m.ID) } type pricingRow struct { - ModelId int64 `orm:"model_id" json:"model_id"` - BillingMode string `orm:"billing_mode" json:"billing_mode"` - MinTokens int64 `orm:"min_tokens" json:"min_tokens"` - MaxTokens *int64 `orm:"max_tokens" json:"max_tokens"` - InputPrice float64 `orm:"input_price" json:"input_price"` - OutputPrice float64 `orm:"output_price" json:"output_price"` - PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"` - CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"` - CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"` - TimeSegments string `orm:"time_segments" json:"time_segments"` + ModelId int64 `orm:"model_id" json:"model_id"` + BillingMode string `orm:"billing_mode" json:"billing_mode"` + Pricing string `orm:"pricing" json:"pricing"` // pricing JSONB(唯一真相) } var allPricingRows []pricingRow if len(modelIDs) > 0 { @@ -99,25 +100,58 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe return nil, err } } - // 按 model_id 分组,供后续组装;时段定价只认锚点行(min_tokens=0) + // 按 model_id 组装导出数据:pricing JSONB 为唯一真相 + // (tiered 的 tiers 数组还原为多行 exportPricing,与导入提交的 items 结构互逆) pricingByModel := make(map[int64][]exportPricing, len(allPricingRows)) segmentsByModel := make(map[int64][]v1.TimeSegmentItem, len(allPricingRows)) + paramRulesByModel := make(map[int64][]v1.ParamMultiplierItem, len(allPricingRows)) + schemeByModel := make(map[int64]string, len(allPricingRows)) + schemeConfigByModel := make(map[int64]json.RawMessage, len(allPricingRows)) for _, p := range allPricingRows { - pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{ - BillingMode: p.BillingMode, - MinTokens: p.MinTokens, - MaxTokens: p.MaxTokens, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - PerRequestPrice: p.PerRequestPrice, - CacheReadPrice: p.CacheReadPrice, - CacheCreationPrice: p.CacheCreationPrice, - }) - if p.MinTokens == 0 && p.TimeSegments != "" && p.TimeSegments != "null" { - var segs []billing.TimeSegment - if err := json.Unmarshal([]byte(p.TimeSegments), &segs); err == nil { - segmentsByModel[p.ModelId] = timeSegmentsToAPI(segs) + blob := billing.ParsePricingBlob(p.Pricing) + if blob == nil { + blob = &billing.PricingBlob{} + } + // tiered:阶梯数组展开为多行;其余模式单行 + if len(blob.Tiers) > 0 { + for _, tier := range blob.Tiers { + pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{ + BillingMode: p.BillingMode, + MinTokens: tier.MinTokens, + MaxTokens: tier.MaxTokens, + InputPrice: tier.InputPrice, + OutputPrice: tier.OutputPrice, + }) } + } else { + ep := exportPricing{ + BillingMode: p.BillingMode, + PerSecondPrices: blob.Prices, + } + if blob.InputPrice != nil { + ep.InputPrice = *blob.InputPrice + } + if blob.OutputPrice != nil { + ep.OutputPrice = *blob.OutputPrice + } + ep.PerRequestPrice = blob.Price + if blob.CacheReadPrice != nil { + ep.CacheReadPrice = *blob.CacheReadPrice + } + if blob.CacheCreationPrice != nil { + ep.CacheCreationPrice = *blob.CacheCreationPrice + } + pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], ep) + } + if len(blob.TimeSegments) > 0 { + segmentsByModel[p.ModelId] = timeSegmentsToAPI(blob.TimeSegments) + } + if len(blob.ParamMultipliers) > 0 { + paramRulesByModel[p.ModelId] = paramMultipliersToAPI(blob.ParamMultipliers) + } + if blob.Scheme != "" { + schemeByModel[p.ModelId] = blob.Scheme + schemeConfigByModel[p.ModelId] = blob.SchemeConfig } } @@ -127,6 +161,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, Status: m.Status, MaxContextTokens: m.MaxContextTokens, MaxOutputTokens: m.MaxOutputTokens, @@ -135,6 +170,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe ReplacementModel: m.ReplacementModel, Pricing: pricingByModel[m.ID], TimeSegments: segmentsByModel[m.ID], + ParamMultipliers: paramRulesByModel[m.ID], + Scheme: schemeByModel[m.ID], + SchemeConfig: schemeConfigByModel[m.ID], } if em.Pricing == nil { em.Pricing = []exportPricing{} @@ -237,6 +275,23 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. res := &v1.ModelImportRes{} err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, item := range req.Models { + // 计费方案配对校验(与 SetModelPricing 同口径):special ⇔ scheme 声明, + // 且方案必须已注册——导入文件引用本环境不存在的方案时运行期会 fail-closed + // 拒绝该模型请求,应在导入时报错而非留隐患 + scheme := strings.TrimSpace(item.Scheme) + if len(item.Pricing) > 0 { + isSpecial := item.Pricing[0].BillingMode == billing.BillingModeSpecial + if isSpecial && scheme == "" { + return gerror.Newf("模型 %s:特殊计费模式(special)缺少计费方案声明", item.ModelId) + } + if !isSpecial && scheme != "" { + return gerror.Newf("模型 %s:计费方案模型必须使用特殊计费模式(billing_mode=special)", item.ModelId) + } + } + if scheme != "" && !billing.SchemeRegistered(scheme) { + return gerror.Newf("模型 %s:计费方案 %q 未注册", item.ModelId, scheme) + } + var existing *struct { ID int64 `orm:"id"` } @@ -258,6 +313,10 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. MaxOutputTokens: item.MaxOutputTokens, Description: item.Description, } + // 旧导出文件无 vendor 字段(空串),不覆盖已有厂商配置 + if item.Vendor != "" { + updateData.Vendor = item.Vendor + } if item.Tags != nil { updateData.Tags = item.Tags } @@ -280,32 +339,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. return err } - _, err = dao.MdlPricing.Ctx(ctx).Where("model_id", existing.ID).Delete() - if err != nil { - return err + paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers) + if pErr != nil { + return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId) } - for _, p := range item.Pricing { - // API 边界 float64 → DO decimal 转换 - var perRequestPriceDecimal *decimal.Decimal - if p.PerRequestPrice != nil { - d := billing.NewFromFloat(*p.PerRequestPrice) - perRequestPriceDecimal = &d - } - - _, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ - ModelId: existing.ID, - BillingMode: p.BillingMode, - MinTokens: p.MinTokens, - MaxTokens: p.MaxTokens, - InputPrice: billing.NewFromFloat(p.InputPrice), - OutputPrice: billing.NewFromFloat(p.OutputPrice), - PerRequestPrice: perRequestPriceDecimal, - CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice), - CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice), - }) - if err != nil { - return err - } + if err := writePricingForModel(ctx, existing.ID, item.Pricing, paramRules, scheme, item.SchemeConfig); err != nil { + return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } // 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除) @@ -322,6 +361,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. ModelId: item.ModelId, ModelName: item.ModelName, Category: item.Category, + Vendor: item.Vendor, Status: "active", MaxContextTokens: item.MaxContextTokens, MaxOutputTokens: item.MaxOutputTokens, @@ -342,28 +382,13 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. return err } - for _, p := range item.Pricing { - // API 边界 float64 → DO decimal 转换 - var perRequestPriceDecimal *decimal.Decimal - if p.PerRequestPrice != nil { - d := billing.NewFromFloat(*p.PerRequestPrice) - perRequestPriceDecimal = &d - } - - _, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ - ModelId: id, - BillingMode: p.BillingMode, - MinTokens: p.MinTokens, - MaxTokens: p.MaxTokens, - InputPrice: billing.NewFromFloat(p.InputPrice), - OutputPrice: billing.NewFromFloat(p.OutputPrice), - PerRequestPrice: perRequestPriceDecimal, - CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice), - CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice), - }) - if err != nil { - return err - } + // 定价写入(pricing JSONB,含参数倍率,与 SetModelPricing 口径一致) + paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers) + if pErr != nil { + return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId) + } + if err := writePricingForModel(ctx, id, item.Pricing, paramRules, scheme, item.SchemeConfig); err != nil { + return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } // 时段定价透传(挂锚点行) @@ -376,6 +401,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. } return nil }) + if err != nil { + return res, err + } - return res, err + // 导入含新建 / 更新模型,逐条失效太碎,事务提交后整体失效一次下拉缓存 + InvalidateModelOptionsCache(ctx) + + return res, nil } diff --git a/internal/logic/admin/order.go b/internal/logic/admin/order.go index 3aed35f..459dd09 100644 --- a/internal/logic/admin/order.go +++ b/internal/logic/admin/order.go @@ -230,7 +230,7 @@ func deductWalletForRefundTx(ctx context.Context, orderID int64, tenantID int64) } } if credit == nil { - return billing.Zero, common.NewBadRequestError("未找到该订单的充值入账流水,无法确定应扣回的美元金额") + return billing.Zero, common.NewBadRequestError("未找到该订单的充值入账流水,无法确定应扣回的入账金额") } if !credit.Amount.GreaterThan(billing.Zero) { return billing.Zero, common.NewBadRequestError("充值入账流水金额异常,无法退款") diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 2634ee0..43be5b2 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -6,7 +6,6 @@ import ( "github.com/gogf/gf/v2/database/gdb" do "github.com/qianfree/team-api/internal/model/do" - "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" @@ -66,11 +65,20 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ "order:view", "order:refund", }, }, + { + Name: "payment", + Label: "支付渠道", + Permissions: []string{ + // 渠道配置含商户密钥明文,独立于 system:* 权限点:必须显式授予才能 + // 查看/修改支付渠道,避免"能进系统设置就能拿到收款密钥" + "payment:view", "payment:edit", + }, + }, { Name: "audit", Label: "审计日志", Permissions: []string{ - "audit:view", "audit:export", "audit:read_sensitive", + "audit:view", "audit:export", "audit:read_sensitive", "audit:clear", }, }, { @@ -98,7 +106,8 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "system", Label: "系统设置", Permissions: []string{ - "system:view", "system:edit", "system:update", "system:plugin", + // 注:在线自更新无权限点 —— 整个 update 域硬性限定超管(superAdminOnlyRules) + "system:view", "system:edit", "system:plugin", }, }, { @@ -133,7 +142,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "member", Label: "成员管理", Permissions: []string{ - "member:view", "member:import", "member:model_scope", + "member:view", "member:import", "member:manage", "member:model_scope", }, }, { @@ -152,11 +161,18 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ }, } +// PermissionGroups 返回全部预定义权限点分组。 +// 供 middleware 的权限规则覆盖测试校验「规则引用的权限点确实存在」, +// 避免规则里的拼写错误造成接口对所有非超管角色永久 403。 +func PermissionGroups() []v1.PermissionGroup { + return predefinedPermissionGroups +} + // GetUserPermissions returns permission points and data scopes for an admin user. func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) { // Get permission points var perms []struct { - PermissionPoint string `json:"permission_point"` + PermissionPoint string `json:"permission_point" orm:"permission_point"` } err := dao.SysAdminRolePerms.Ctx(ctx). Where("admin_user_id", req.Id). @@ -172,9 +188,9 @@ func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermission // Get data scopes var scopes []struct { - ID int64 `json:"id"` - ScopeType string `json:"scope_type"` - ScopeValue string `json:"scope_value"` + ID int64 `json:"id" orm:"id"` + ScopeType string `json:"scope_type" orm:"scope_type"` + ScopeValue string `json:"scope_value" orm:"scope_value"` } err = dao.SysAdminDataScopes.Ctx(ctx). Where("admin_user_id", req.Id). @@ -215,6 +231,18 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss if user.Role == "super_admin" { return nil, common.NewBadRequestError("超级管理员无需配置权限") } + if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { + return nil, err + } + + // 特批权限与角色权限一样进有效权限的并集,因此必须走同一道 sanitize: + // 校验权限点存在 + 去重排序 + 禁止授予自己不具备的权限。 + // 此前这里只做「权限点是否存在」的校验,一个拿到 user:edit 的账号可以给自己 + // 特批 system:plugin / billing:refund,绕开「角色管理仅超管」的全部管控。 + perms, err := sanitizeGrantedPermissions(ctx, req.Permissions) + if err != nil { + return nil, err + } // 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。 err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { @@ -226,18 +254,10 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss return err } - // Validate permission points against predefined set - validPerms := buildValidPermissionSet() - for _, p := range req.Permissions { - if !validPerms[p] { - return gerror.Newf("无效的权限点: %s", p) - } - } - // Insert new permissions - if len(req.Permissions) > 0 { - data := make([]do.SysAdminRolePerms, len(req.Permissions)) - for i, p := range req.Permissions { + if len(perms) > 0 { + data := make([]do.SysAdminRolePerms, len(perms)) + for i, p := range perms { data[i] = do.SysAdminRolePerms{ AdminUserId: req.Id, PermissionPoint: p, @@ -255,98 +275,91 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss return nil, err } - return nil, nil -} - -// UpdateUserDataScopes updates data scopes for an admin user. -func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) { - // Check if target is super_admin - var user *struct { - Role string `json:"role"` - } - err := dao.SysAdminUsers.Ctx(ctx). - Where("id", req.Id).Scan(&user) - if err = common.IgnoreScanNoRows(err); err != nil { - return nil, err - } - if user == nil { - return nil, common.NewNotFoundError("用户") - } - if user.Role == "super_admin" { - return nil, common.NewBadRequestError("超级管理员无需配置数据范围") - } - - // 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。 - err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - // Delete existing data scopes - _, err := dao.SysAdminDataScopes.Ctx(ctx). - Where("admin_user_id", req.Id). - Delete() - if err != nil { - return err - } - - // Insert new data scopes - if len(req.DataScopes) > 0 { - data := make([]do.SysAdminDataScopes, len(req.DataScopes)) - for i, sc := range req.DataScopes { - data[i] = do.SysAdminDataScopes{ - AdminUserId: req.Id, - ScopeType: sc.ScopeType, - ScopeValue: sc.ScopeValue, - } - } - _, err = dao.SysAdminDataScopes.Ctx(ctx).Data(data).Insert() - if err != nil { - return err - } - } - - return nil - }) - if err != nil { - return nil, err - } + // 特批权限参与有效权限计算,变更后必须让该用户的权限缓存失效 + InvalidateUserPermCache(ctx, req.Id) return nil, nil } -// GetAllPermissions returns all predefined permission groups. +// tierLabels 是档位的中文标签,随元数据下发给前端,避免前端硬编码一份。 +var tierLabels = map[string]string{ + TierNone: "无", + TierRead: "只读", + TierOperate: "操作", + TierFull: "完全", +} + +// GetAllPermissions returns all predefined permission groups plus tier metadata. +// +// 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用): +// 二者是同一份数据的两个视图,由后端统一给出,避免前端复刻一份档位定义造成漂移。 func (s *sAdmin) GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) { + modules := make([]v1.PermissionModuleMeta, 0, len(predefinedPermissionGroups)) + for _, grp := range predefinedPermissionGroups { + tiers := ModuleTiers(grp.Name) + opts := make([]v1.TierOption, 0, len(tiers)) + for _, tier := range tiers { + opts = append(opts, v1.TierOption{ + Tier: tier, + Label: tierLabels[tier], + Permissions: TierPermissions(grp.Name, tier), + }) + } + modules = append(modules, v1.PermissionModuleMeta{ + Module: grp.Name, + Label: grp.Label, + Tiers: opts, + }) + } + return &v1.AdminAllPermissionsRes{ - Groups: predefinedPermissionGroups, + Groups: predefinedPermissionGroups, + Modules: modules, + Dangerous: dangerousPermissions, }, nil } // HasPermission checks if an admin user has a specific permission point. -// super_admin always returns true. +// +// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限,详见 GetEffectivePermissions。 +// 结果走 300s 双层缓存:鉴权在每个请求上都跑,此前每次一条 COUNT 查询。 +// +// 签名保持不变,middleware/rbac.go 无需改动。 +// super_admin 直接放行,不查表也不进缓存。 func HasPermission(ctx context.Context, userID int64, role string, permission string) bool { if role == "super_admin" { return true } - count, err := dao.SysAdminRolePerms.Ctx(ctx). - Where("admin_user_id", userID). - Where("permission_point", permission). - Count() + perms, err := GetEffectivePermissions(ctx, userID, role) if err != nil { + // 查询失败时 fail-closed:宁可误拒也不误放,鉴权失败不应静默放行 return false } - - return count > 0 + for _, p := range perms { + if p == permission { + return true + } + } + return false } // GetDataScopes returns data scopes for an admin user. // super_admin always returns "all". +// +// ⚠️ 当前无调用方:数据范围只被存储与回显,没有任何查询按它过滤,配置了也不生效。 +// 写接口已随之下线(见 api/admin/v1/permission.go)。要让这个特性真正成立, +// 必须先把它接进各列表查询的 WHERE 条件,再恢复配置入口 —— 在那之前不要仅凭 +// 本函数存在就认为数据范围已被强制执行。 func GetDataScopes(ctx context.Context, userID int64, role string) ([]v1.DataScopeItem, error) { if role == "super_admin" { return []v1.DataScopeItem{{ScopeType: "all"}}, nil } var scopes []struct { - ID int64 `json:"id"` - ScopeType string `json:"scope_type"` - ScopeValue string `json:"scope_value"` + ID int64 `json:"id" orm:"id"` + ScopeType string `json:"scope_type" orm:"scope_type"` + ScopeValue string `json:"scope_value" orm:"scope_value"` } err := dao.SysAdminDataScopes.Ctx(ctx). Where("admin_user_id", userID). diff --git a/internal/logic/admin/privilege_guard_test.go b/internal/logic/admin/privilege_guard_test.go new file mode 100644 index 0000000..343a7b4 --- /dev/null +++ b/internal/logic/admin/privilege_guard_test.go @@ -0,0 +1,68 @@ +package admin + +import ( + "context" + "testing" +) + +// ctxAs 构造带调用者身份的上下文。 +// 键名与 middleware 写入 ctx 的保持一致(见 internal/logic/common/session.go)。 +func ctxAs(role string, userID int64) context.Context { + ctx := context.WithValue(context.Background(), "role", role) + return context.WithValue(ctx, "userId", userID) +} + +// TestAssertCanGrantSuperAdmin 守住「谁能把账号设成超级管理员」。 +// +// sys_admin_users.role 是超管的唯一判定依据,鉴权时直接短路放行。若允许非超管写这个字段, +// 一个拿到 user:create / user:edit 的账号可以直接把自己提成超管 —— 比分配角色更直接, +// 且完全绕过 superAdminOnlyRules。字段级取值校验(ValidateAdminRole)管不了这件事。 +func TestAssertCanGrantSuperAdmin(t *testing.T) { + cases := []struct { + name string + operator string + targetRole string + wantBlocked bool + }{ + {"超管可以设置超管", "super_admin", "super_admin", false}, + {"超管可以设置普通管理员", "super_admin", "admin", false}, + {"非超管不能设置超管", "admin", "super_admin", true}, + {"非超管可以设置普通管理员", "admin", "admin", false}, + {"无身份上下文时不能设置超管", "", "super_admin", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := assertCanGrantSuperAdmin(ctxAs(c.operator, 1), c.targetRole) + if c.wantBlocked && err == nil { + t.Fatalf("应被拦截,实际放行") + } + if !c.wantBlocked && err != nil { + t.Fatalf("应放行,实际被拦截: %v", err) + } + }) + } +} + +// TestAssertCanManageAdminUserBlocksSuperAdminTarget 守住「非超管不得操作超管账号」。 +// +// 账号管理(改密/禁用/删除/配特批权限)按 user:* 授权、允许下放。若不限制目标, +// 拿到 user:edit 的账号重置一次超管密码就能接管系统,角色管理限定超管那道闸门形同虚设。 +// +// 只覆盖不触库的分支:超管调用者短路、目标为超管、无调用者身份、操作自己。 +// 「目标权限须是调用者子集」需要读库,由集成测试覆盖。 +func TestAssertCanManageAdminUserBlocksSuperAdminTarget(t *testing.T) { + if err := assertCanManageAdminUser(ctxAs("super_admin", 1), 2, "super_admin"); err != nil { + t.Fatalf("超管操作任意账号应放行: %v", err) + } + if err := assertCanManageAdminUser(ctxAs("admin", 2), 1, "super_admin"); err == nil { + t.Fatal("非超管操作超管账号应被拦截,实际放行") + } + // 无调用者身份(命令行工具、内部调用):交由上层接口鉴权把关,此处不拦 + if err := assertCanManageAdminUser(ctxAs("admin", 0), 3, "admin"); err != nil { + t.Fatalf("无调用者身份时不应拦截: %v", err) + } + // 操作自己:自身权限的变更另有 sanitizeGrantedPermissions 把关 + if err := assertCanManageAdminUser(ctxAs("admin", 5), 5, "admin"); err != nil { + t.Fatalf("操作自己不应被拦截: %v", err) + } +} diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go new file mode 100644 index 0000000..90c22f9 --- /dev/null +++ b/internal/logic/admin/role.go @@ -0,0 +1,499 @@ +package admin + +import ( + "context" + "regexp" + "sort" + "strings" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/model/do" +) + +// ───────────────────────────────────────────────────────────────────────────── +// 权限档位模型 +// ───────────────────────────────────────────────────────────────────────────── + +// 权限档位:配置界面按「模块 × 档位」呈现,而不是把 60 个权限点摊成复选框。 +// 档位按【可逆性】而非【读/写/删】划分 —— 典型例子是租户:停用(suspend)是可撤销的 +// 日常运营动作,删除与销户不可逆。若把三者塞进同一档,就会被迫在「运营不能停服」和 +// 「运营能删客户」之间二选一。 +const ( + TierNone = "none" // 无:该模块不可见 + TierRead = "read" // 只读:只能查看 + TierOperate = "operate" // 操作:日常增改与业务动作 + TierFull = "full" // 完全:含删除与高风险动作 +) + +// tierPermissions 是「模块 + 档位 → 权限点集合」的静态映射表。 +// +// 刻意手写而非按 view/create/edit/delete 规则化推导:各模块的权限点语义差异太大 +// (billing:refund 是资金操作、audit:read_sensitive 是隐私、member:model_scope 是配置), +// 规则化只会产生错误的归类。高档位包含低档位的全部权限点。 +var tierPermissions = map[string]map[string][]string{ + "dashboard": { + TierRead: {"dashboard:view"}, + }, + "tenant": { + TierRead: {"tenant:view"}, + TierOperate: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend"}, + TierFull: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close"}, + }, + "member": { + TierRead: {"member:view"}, + TierOperate: {"member:view", "member:import", "member:manage"}, + TierFull: {"member:view", "member:import", "member:manage", "member:model_scope"}, + }, + "channel": { + TierRead: {"channel:view"}, + TierOperate: {"channel:view", "channel:create", "channel:edit", "channel:test"}, + TierFull: {"channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete"}, + }, + "model": { + TierRead: {"model:view"}, + TierOperate: {"model:view", "model:create", "model:edit"}, + TierFull: {"model:view", "model:create", "model:edit", "model:delete"}, + }, + "billing": { + TierRead: {"billing:view"}, + TierOperate: {"billing:view", "billing:export"}, + TierFull: {"billing:view", "billing:export", "billing:refund"}, + }, + "plan": { + TierRead: {"plan:view"}, + TierOperate: {"plan:view", "plan:create", "plan:edit"}, + TierFull: {"plan:view", "plan:create", "plan:edit", "plan:delete"}, + }, + "order": { + TierRead: {"order:view"}, + TierFull: {"order:view", "order:refund"}, + }, + // 支付渠道只有查看/编辑两个动作,无删除档:密钥类配置宁可少放开 + "payment": { + TierRead: {"payment:view"}, + TierOperate: {"payment:view", "payment:edit"}, + }, + "promo": { + TierRead: {"promo:view"}, + TierOperate: {"promo:view", "promo:create", "promo:edit"}, + }, + "redemption": { + TierRead: {"redemption:view"}, + TierOperate: {"redemption:view", "redemption:create", "redemption:edit"}, + }, + "invoice": { + TierRead: {"invoice:view"}, + TierOperate: {"invoice:view", "invoice:manage"}, + }, + "operation": { + TierRead: {"operation:view"}, + TierOperate: {"operation:view", "operation:edit"}, + }, + "support": { + TierRead: {"support:view"}, + TierOperate: {"support:view", "support:reply"}, + TierFull: {"support:view", "support:reply", "support:edit"}, + }, + "monitor": { + TierRead: {"monitor:view"}, + TierOperate: {"monitor:view", "monitor:edit"}, + }, + "task": { + TierRead: {"task:view"}, + TierOperate: {"task:view", "task:edit"}, + }, + "file": { + TierRead: {"file:view"}, + TierFull: {"file:view", "file:delete", "file:cleanup"}, + }, + "audit": { + TierRead: {"audit:view"}, + TierOperate: {"audit:view", "audit:export"}, + TierFull: {"audit:view", "audit:export", "audit:read_sensitive", "audit:clear"}, + }, + "user": { + TierRead: {"user:view"}, + TierOperate: {"user:view", "user:create", "user:edit"}, + TierFull: {"user:view", "user:create", "user:edit", "user:delete"}, + }, + "system": { + TierRead: {"system:view"}, + TierOperate: {"system:view", "system:edit"}, + // 在线自更新硬性限定超管、无权限点,「完全」档与「操作」档的差异只剩插件 + TierFull: {"system:view", "system:edit", "system:plugin"}, + }, +} + +// dangerousPermissions 是需要在界面上标红并二次确认的高危权限点。 +// 四类:资金、隐私、系统、提权。 +var dangerousPermissions = map[string]string{ + "billing:refund": "可对任意租户钱包退款/调账", + "order:refund": "可对订单发起退款到支付渠道", + "payment:edit": "可修改支付渠道配置与商户密钥,直接影响线上收款", + "audit:read_sensitive": "可查看敏感数据访问日志", + "audit:clear": "可硬删除全部拦截日志(不可恢复)", + "system:edit": "可修改支付、汇率、存储、邮件等全站配置", + "system:plugin": "可安装/启停插件(等同于部署代码)", + "user:create": "可创建管理员账号", + "user:edit": "可修改管理员账号与其角色(含提权)", + "user:delete": "可删除管理员账号", + "member:manage": "可重置任意租户成员的密码(等同接管该成员账号)", +} + +// tierOrder 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。 +var tierOrder = []string{TierNone, TierRead, TierOperate, TierFull} + +// roleCodeRegexp 限制角色标识的取值:小写字母、数字、下划线,2-50 字符。 +// code 参与权限缓存键与审计日志,必须稳定且可读。 +var roleCodeRegexp = regexp.MustCompile(`^[a-z][a-z0-9_]{1,49}$`) + +// reservedRoleCode 是保留字:超级管理员由 sys_admin_users.role 判定并短路鉴权, +// 不允许在角色表里出现同名角色造成语义混淆。 +const reservedRoleCode = "super_admin" + +// TierPermissions 返回指定模块与档位对应的权限点集合。 +// 档位不存在(如 dashboard 没有 operate 档)时返回 nil,调用方应回落到更低档位。 +func TierPermissions(module, tier string) []string { + return tierPermissions[module][tier] +} + +// ModuleTiers 返回某模块实际存在差异的档位列表(含 none,由低到高)。 +// +// 界面只渲染这些档位:dashboard 只有 无/只读,promo 只有 无/只读/操作。 +// 避免让用户在「操作」和「完全」两个完全等价的选项之间纠结。 +func ModuleTiers(module string) []string { + defs, ok := tierPermissions[module] + if !ok { + return []string{TierNone} + } + out := []string{TierNone} + var prev []string + for _, tier := range tierOrder[1:] { + perms, exists := defs[tier] + if !exists || samePermissionSet(perms, prev) { + continue + } + out = append(out, tier) + prev = perms + } + return out +} + +// TierForPermissions 从一组权限点反推它属于某模块的哪个档位。 +// 完全匹配某档位时返回该档位,否则返回空串表示「自定义」(界面切到高级模式展示)。 +func TierForPermissions(module string, perms []string) string { + owned := make([]string, 0, len(perms)) + prefix := module + ":" + for _, p := range perms { + if strings.HasPrefix(p, prefix) { + owned = append(owned, p) + } + } + if len(owned) == 0 { + return TierNone + } + // 由高到低比对,命中即返回(高档位是低档位的超集,先比高档避免误判) + for i := len(tierOrder) - 1; i >= 1; i-- { + if defs, ok := tierPermissions[module][tierOrder[i]]; ok && samePermissionSet(owned, defs) { + return tierOrder[i] + } + } + return "" +} + +// IsDangerousPermission 判断权限点是否属于需要二次确认的高危权限。 +func IsDangerousPermission(perm string) (string, bool) { + reason, ok := dangerousPermissions[perm] + return reason, ok +} + +func samePermissionSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + sa := append([]string(nil), a...) + sb := append([]string(nil), b...) + sort.Strings(sa) + sort.Strings(sb) + for i := range sa { + if sa[i] != sb[i] { + return false + } + } + return true +} + +// ───────────────────────────────────────────────────────────────────────────── +// 有效权限计算与缓存 +// ───────────────────────────────────────────────────────────────────────────── + +// adminPermCache 缓存每个管理员的有效权限集合。 +// +// 鉴权在每个请求上都要跑,此前是一次 COUNT 查询。复用项目的双层缓存 +// (L1 gcache + L2 Redis + 跨实例失效订阅),TTL 与「用户信息 300s」的既定策略一致。 +var adminPermCache = lcommon.NewCache("admin_perm", 300*time.Second) + +// permCacheKey 按用户维度组织缓存键。 +func permCacheKey(userID int64) string { + return g.NewVar(userID).String() +} + +// InvalidateUserPermCache 清除单个用户的权限缓存(角色关联、特批权限变更时调用)。 +func InvalidateUserPermCache(ctx context.Context, userID int64) { + adminPermCache.Delete(ctx, permCacheKey(userID)) +} + +// InvalidateAllPermCache 清空全部管理员的权限缓存。 +// +// 角色维度的变更(改角色权限、启停、删除)会影响该角色下的所有用户。管理员账号在 +// 几十量级且这类操作低频,直接清空整个前缀比精确定位受影响用户更简单也更不容易漏。 +func InvalidateAllPermCache(ctx context.Context) { + adminPermCache.DeleteByPattern(ctx, "*") +} + +// GetEffectivePermissions 返回管理员的有效权限点集合。 +// +// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms) +// +// 只做并集不做否定:带 deny 规则的 RBAC 在排查「这个人为什么没权限」时需要回溯整条 +// 规则链,运维成本远大于收益。要收权限就去掉角色或改角色。 +// +// super_admin 不查表,直接返回全部权限点。 +func GetEffectivePermissions(ctx context.Context, userID int64, role string) ([]string, error) { + if role == "super_admin" { + return AllPermissionPoints(), nil + } + + var cached []string + if adminPermCache.GetJSON(ctx, permCacheKey(userID), &cached) { + return cached, nil + } + + perms, err := loadEffectivePermissions(ctx, userID) + if err != nil { + return nil, err + } + adminPermCache.Set(ctx, permCacheKey(userID), perms) + return perms, nil +} + +// loadEffectivePermissions 从数据库计算有效权限(不走缓存)。 +func loadEffectivePermissions(ctx context.Context, userID int64) ([]string, error) { + set := make(map[string]bool) + + // 1) 角色权限:仅统计已启用的角色。 + // 角色被禁用后其权限立即失效,不需要解除用户关联。 + var rolePerms []struct { + PermissionPoint string `json:"permission_point"` + } + err := dao.SysAdminRolePermissions.Ctx(ctx). + As("rp"). + LeftJoin("sys_admin_roles r", "r.id = rp.role_id"). + LeftJoin("sys_admin_user_roles ur", "ur.role_id = rp.role_id"). + Where("ur.admin_user_id", userID). + Where("r.is_enabled", true). + Fields("rp.permission_point"). + Scan(&rolePerms) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + for _, p := range rolePerms { + set[p.PermissionPoint] = true + } + + // 2) 用户特批权限:独立于角色,角色禁用不影响特批。 + var extraPerms []struct { + PermissionPoint string `json:"permission_point"` + } + err = dao.SysAdminRolePerms.Ctx(ctx). + Where("admin_user_id", userID). + Fields("permission_point"). + Scan(&extraPerms) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + for _, p := range extraPerms { + set[p.PermissionPoint] = true + } + + out := make([]string, 0, len(set)) + for p := range set { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// AllPermissionPoints 返回系统定义的全部权限点(超级管理员使用)。 +func AllPermissionPoints() []string { + var out []string + for _, grp := range predefinedPermissionGroups { + out = append(out, grp.Permissions...) + } + sort.Strings(out) + return out +} + +// ───────────────────────────────────────────────────────────────────────────── +// 用户角色关联 +// ───────────────────────────────────────────────────────────────────────────── + +// GetUserRoleBriefs 返回用户已分配角色的简要信息(登录响应与用户列表展示用)。 +func GetUserRoleBriefs(ctx context.Context, userID int64) ([]v1.AdminRoleBrief, error) { + var rows []v1.AdminRoleBrief + err := dao.SysAdminUserRoles.Ctx(ctx). + As("ur"). + LeftJoin("sys_admin_roles r", "r.id = ur.role_id"). + Where("ur.admin_user_id", userID). + Fields("r.id, r.code, r.name, r.is_enabled"). + OrderAsc("r.sort"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + return rows, nil +} + +// replaceUserRoles 全量覆盖用户的角色关联(调用方负责事务与缓存失效)。 +func replaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error { + if _, err := dao.SysAdminUserRoles.Ctx(ctx). + Where("admin_user_id", userID).Delete(); err != nil { + return err + } + if len(roleIDs) == 0 { + return nil + } + + // 去重,避免前端重复提交同一角色触发唯一约束 + seen := make(map[int64]bool, len(roleIDs)) + data := make([]do.SysAdminUserRoles, 0, len(roleIDs)) + for _, id := range roleIDs { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + data = append(data, do.SysAdminUserRoles{AdminUserId: userID, RoleId: id}) + } + if len(data) == 0 { + return nil + } + + // 校验角色存在,避免写入悬空关联(无外键,DB 不会拦) + count, err := dao.SysAdminRoles.Ctx(ctx).WhereIn("id", roleIDs).Count() + if err != nil { + return err + } + if count != len(data) { + return lcommon.NewBadRequestError("包含不存在的角色") + } + + _, err = dao.SysAdminUserRoles.Ctx(ctx).Data(data).Insert() + return err +} + +// AssignUserRoles 为管理员分配角色(全量覆盖)。 +func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + user, err := loadAdminUserRole(ctx, req.Id) + if err != nil { + return nil, err + } + if user.Role == "super_admin" { + return nil, lcommon.NewBadRequestError("超级管理员拥有全部权限,无需分配角色") + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return replaceUserRoles(ctx, req.Id, req.RoleIDs) + }) + if err != nil { + return nil, err + } + + InvalidateUserPermCache(ctx, req.Id) + return &v1.AdminUserRoleAssignRes{}, nil +} + +// GetUserRoles 查询管理员已分配的角色。 +func (s *sAdmin) GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + if _, err := loadAdminUserRole(ctx, req.Id); err != nil { + return nil, err + } + briefs, err := GetUserRoleBriefs(ctx, req.Id) + if err != nil { + return nil, err + } + if briefs == nil { + briefs = []v1.AdminRoleBrief{} + } + return &v1.AdminUserRoleListRes{List: briefs}, nil +} + +// loadAdminUserRole 读取管理员账号的特权标记,顺带校验账号存在。 +func loadAdminUserRole(ctx context.Context, userID int64) (*struct { + Role string `json:"role"` +}, error) { + var user *struct { + Role string `json:"role"` + } + err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Fields("role").Scan(&user) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if user == nil { + return nil, lcommon.NewNotFoundError("用户") + } + return user, nil +} + +// loadRolesByUsers 批量查询多个管理员的角色,返回 userID → 角色列表。 +// +// 用户列表页要展示每个账号的角色,逐个查询会产生 N+1;这里一次 IN 查询后在内存里分组。 +// 结果保证每个入参 userID 都有对应条目(无角色者为空切片),前端可直接据此提示「未分配角色」。 +func loadRolesByUsers(ctx context.Context, userIDs []int64) (map[int64][]v1.AdminRoleBrief, error) { + out := make(map[int64][]v1.AdminRoleBrief, len(userIDs)) + for _, id := range userIDs { + out[id] = []v1.AdminRoleBrief{} + } + if len(userIDs) == 0 { + return out, nil + } + + var rows []struct { + AdminUserId int64 `json:"admin_user_id"` + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + IsEnabled bool `json:"is_enabled"` + } + err := dao.SysAdminUserRoles.Ctx(ctx). + As("ur"). + LeftJoin("sys_admin_roles r", "r.id = ur.role_id"). + WhereIn("ur.admin_user_id", userIDs). + Fields("ur.admin_user_id, r.id, r.code, r.name, r.is_enabled"). + OrderAsc("r.sort"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + for _, r := range rows { + out[r.AdminUserId] = append(out[r.AdminUserId], v1.AdminRoleBrief{ + ID: r.ID, + Code: r.Code, + Name: r.Name, + IsEnabled: r.IsEnabled, + }) + } + return out, nil +} diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go new file mode 100644 index 0000000..e114eb9 --- /dev/null +++ b/internal/logic/admin/role_crud.go @@ -0,0 +1,540 @@ +package admin + +import ( + "context" + "sort" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/dao" + lcommon "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/model/do" +) + +// builtinRoleDefaults 是三个预置角色的默认权限,用于「恢复默认权限」。 +// +// 与 migrations/000020_admin_role_management.sql 的种子数据必须一致, +// 由 TestBuiltinRoleDefaultsMatchMigration 在 CI 阶段比对,防止两处漂移。 +// +// 注意这些只是初始值而非系统契约:用户可以任意修改,甚至删掉预置角色自建一套。 +var builtinRoleDefaults = map[string][]string{ + // 管理员:除 user:*(账号与角色)与 system:plugin 外的全部权限 + //(在线自更新为超管专属,不设权限点)。 + // 与运营的区别就是「能碰钱(退款)和系统配置」—— 去掉这两项,两个角色就没有分开的必要。 + "admin": { + "dashboard:view", + "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", + "model:view", "model:create", "model:edit", "model:delete", + "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close", + "member:view", "member:import", "member:manage", "member:model_scope", + "plan:view", "plan:create", "plan:edit", "plan:delete", + "billing:view", "billing:export", "billing:refund", + "order:view", "order:refund", + "promo:view", "promo:create", "promo:edit", + "redemption:view", "redemption:create", "redemption:edit", + "invoice:view", "invoice:manage", + "operation:view", "operation:edit", + "support:view", "support:reply", "support:edit", + "monitor:view", "monitor:edit", + "task:view", "task:edit", + "file:view", "file:delete", "file:cleanup", + "audit:view", "audit:export", + "system:view", "system:edit", + }, + // 运营:平台日常运营,渠道与模型的完整权限(配渠道就得填 Key,这是本职工作)。 + // 不给退款、审计、系统设置、账号管理。租户停在「操作」档:能停服,不能删除与销户。 + "operator": { + "dashboard:view", + "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", + "model:view", "model:create", "model:edit", "model:delete", + "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", + "member:view", "member:import", "member:manage", "member:model_scope", + "plan:view", "plan:create", "plan:edit", "plan:delete", + "billing:view", + "order:view", + "promo:view", "promo:create", "promo:edit", + "redemption:view", "redemption:create", "redemption:edit", + "invoice:view", "invoice:manage", + "operation:view", "operation:edit", + "support:view", "support:reply", "support:edit", + "monitor:view", "monitor:edit", + "task:view", "task:edit", + "file:view", "file:delete", "file:cleanup", + }, + // 技术支持:除财务面外全站只读 + 工单完整权限。 + // 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。 + "support": { + "dashboard:view", + "channel:view", + "model:view", + "tenant:view", + "member:view", + "plan:view", + "operation:view", + "support:view", "support:reply", "support:edit", + "monitor:view", + "task:view", + "file:view", + "audit:view", + }, +} + +// assertSuperAdmin 校验调用者是超级管理员。 +// +// 与 middleware 的 superAdminOnlyRules 是两道独立的闸门:中间件按路由拦截,这里按方法拦截。 +// 双重校验不是冗余 —— 路由表漏配、内部调用绕过 HTTP 层、将来有人给角色接口加了新路径, +// 任何一种情况下都还有一道守住。权限体系的元操作值得这个代价。 +func assertSuperAdmin(ctx context.Context) error { + if lcommon.GetCtxUserRole(ctx) == "super_admin" { + return nil + } + return lcommon.NewBusinessError(consts.CodeForbidden, "角色权限管理仅超级管理员可用") +} + +// ListRoles 返回角色列表(角色数量少,不分页)。 +func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + var roles []struct { + ID int64 `json:"id" orm:"id"` + Code string `json:"code" orm:"code"` + Name string `json:"name" orm:"name"` + Description string `json:"description" orm:"description"` + IsBuiltin bool `json:"is_builtin" orm:"is_builtin"` + IsEnabled bool `json:"is_enabled" orm:"is_enabled"` + Sort int `json:"sort" orm:"sort"` + CreatedAt string `json:"created_at" orm:"created_at"` + } + err := dao.SysAdminRoles.Ctx(ctx).OrderAsc("sort").OrderAsc("id").Scan(&roles) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + + userCounts, err := countByRole(ctx, dao.SysAdminUserRoles.Ctx(ctx)) + if err != nil { + return nil, err + } + permCounts, err := countByRole(ctx, dao.SysAdminRolePermissions.Ctx(ctx)) + if err != nil { + return nil, err + } + + list := make([]v1.AdminRoleItem, len(roles)) + for i, r := range roles { + list[i] = v1.AdminRoleItem{ + ID: r.ID, + Code: r.Code, + Name: r.Name, + Description: r.Description, + IsBuiltin: r.IsBuiltin, + IsEnabled: r.IsEnabled, + Sort: r.Sort, + UserCount: userCounts[r.ID], + PermCount: permCounts[r.ID], + CreatedAt: r.CreatedAt, + } + } + return &v1.AdminRoleListRes{List: list}, nil +} + +// countByRole 按 role_id 聚合计数,避免在角色列表里对每个角色各查一次(N+1)。 +func countByRole(ctx context.Context, model *gdb.Model) (map[int64]int, error) { + var rows []struct { + RoleId int64 `json:"role_id"` + Cnt int `json:"cnt"` + } + err := model.Fields("role_id, COUNT(*) AS cnt").Group("role_id").Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + out := make(map[int64]int, len(rows)) + for _, r := range rows { + out[r.RoleId] = r.Cnt + } + return out, nil +} + +// GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。 +func (s *sAdmin) GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + role, err := loadRole(ctx, req.ID) + if err != nil { + return nil, err + } + + perms, err := loadRolePermissions(ctx, req.ID) + if err != nil { + return nil, err + } + + userCount, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count() + if err != nil { + return nil, err + } + + return &v1.AdminRoleDetailRes{ + ID: role.ID, + Code: role.Code, + Name: role.Name, + Description: role.Description, + IsBuiltin: role.IsBuiltin, + IsEnabled: role.IsEnabled, + Sort: role.Sort, + UserCount: userCount, + Permissions: perms, + ModuleTiers: buildModuleTiers(perms), + CreatedAt: role.CreatedAt, + }, nil +} + +// buildModuleTiers 把权限点集合归纳成每个模块的档位选择,供界面按档位渲染。 +func buildModuleTiers(perms []string) []v1.AdminRoleModuleTier { + modules := make([]string, 0, len(tierPermissions)) + for m := range tierPermissions { + modules = append(modules, m) + } + sort.Strings(modules) + + out := make([]v1.AdminRoleModuleTier, 0, len(modules)) + for _, m := range modules { + out = append(out, v1.AdminRoleModuleTier{Module: m, Tier: TierForPermissions(m, perms)}) + } + return out +} + +// CreateRole 新建角色。权限可直接给出,也可从现有角色复制。 +func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + code := strings.ToLower(strings.TrimSpace(req.Code)) + if code == reservedRoleCode { + return nil, lcommon.NewBadRequestError("super_admin 是保留标识,超级管理员由账号属性判定,不能作为角色") + } + if !roleCodeRegexp.MatchString(code) { + return nil, lcommon.NewBadRequestError("角色标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2-50") + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, lcommon.NewBadRequestError("请输入角色名称") + } + + exists, err := dao.SysAdminRoles.Ctx(ctx).Where("code", code).Count() + if err != nil { + return nil, err + } + if exists > 0 { + return nil, lcommon.NewBadRequestError("角色标识已存在:" + code) + } + + // 权限来源:显式给出的权限点优先,其次从指定角色复制 + perms := req.Permissions + if len(perms) == 0 && req.CopyFromRoleID > 0 { + if _, err := loadRole(ctx, req.CopyFromRoleID); err != nil { + return nil, err + } + if perms, err = loadRolePermissions(ctx, req.CopyFromRoleID); err != nil { + return nil, err + } + } + + perms, err = sanitizeGrantedPermissions(ctx, perms) + if err != nil { + return nil, err + } + + var newID int64 + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := dao.SysAdminRoles.Ctx(ctx).Data(do.SysAdminRoles{ + Code: code, + Name: name, + Description: strings.TrimSpace(req.Description), + IsBuiltin: false, + IsEnabled: true, + Sort: req.Sort, + }).Insert() + if err != nil { + if lcommon.IsDuplicateKeyError(err) { + return lcommon.NewBadRequestError("角色标识已存在:" + code) + } + return err + } + newID, err = result.LastInsertId() + if err != nil { + return err + } + return replaceRolePermissions(ctx, newID, perms) + }) + if err != nil { + return nil, err + } + + return &v1.AdminRoleCreateRes{ID: newID}, nil +} + +// UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。 +func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + + data := g.Map{} + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, lcommon.NewBadRequestError("角色名称不能为空") + } + data["name"] = name + } + if req.Description != nil { + data["description"] = strings.TrimSpace(*req.Description) + } + if req.Sort != nil { + data["sort"] = *req.Sort + } + + var perms []string + if req.Permissions != nil { + var err error + if perms, err = sanitizeGrantedPermissions(ctx, *req.Permissions); err != nil { + return nil, err + } + } + + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if len(data) > 0 { + data["updated_at"] = gdb.Raw("now()") + if _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(data).Update(); err != nil { + return err + } + } + if req.Permissions != nil { + return replaceRolePermissions(ctx, req.ID, perms) + } + return nil + }) + if err != nil { + return nil, err + } + + // 角色权限变更影响该角色下的全部用户,直接清空前缀 + if req.Permissions != nil { + InvalidateAllPermCache(ctx) + } + return &v1.AdminRoleUpdateRes{}, nil +} + +// UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。 +func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(g.Map{ + "is_enabled": req.IsEnabled, + "updated_at": gdb.Raw("now()"), + }).Update() + if err != nil { + return nil, err + } + InvalidateAllPermCache(ctx) + return &v1.AdminRoleStatusUpdateRes{}, nil +} + +// DeleteRole 删除角色,并在同一事务内级联清理其权限行与用户关联。 +// +// 无外键,级联必须由业务层完成,否则会留下悬空的权限行与用户关联。 +// 预置角色同样可删:is_builtin 只标识来源,不构成删除保护。 +// +// 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权, +// 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。 +func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + if _, err := loadRole(ctx, req.ID); err != nil { + return nil, err + } + + affected, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count() + if err != nil { + return nil, err + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil { + return err + } + if _, err := dao.SysAdminRolePermissions.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil { + return err + } + _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Delete() + return err + }) + if err != nil { + return nil, err + } + + InvalidateAllPermCache(ctx) + return &v1.AdminRoleDeleteRes{AffectedUsers: affected}, nil +} + +// ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。 +func (s *sAdmin) ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) { + if err := assertSuperAdmin(ctx); err != nil { + return nil, err + } + role, err := loadRole(ctx, req.ID) + if err != nil { + return nil, err + } + if !role.IsBuiltin { + return nil, lcommon.NewBadRequestError("只有系统预置角色才有默认权限可恢复") + } + defaults, ok := builtinRoleDefaults[role.Code] + if !ok { + return nil, lcommon.NewBadRequestError("该角色没有登记默认权限") + } + + perms, err := sanitizeGrantedPermissions(ctx, defaults) + if err != nil { + return nil, err + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return replaceRolePermissions(ctx, req.ID, perms) + }) + if err != nil { + return nil, err + } + + InvalidateAllPermCache(ctx) + return &v1.AdminRoleResetRes{Permissions: perms}, nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// 内部辅助 +// ───────────────────────────────────────────────────────────────────────────── + +type roleRecord struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + IsBuiltin bool `json:"is_builtin"` + IsEnabled bool `json:"is_enabled"` + Sort int `json:"sort"` + CreatedAt string `json:"created_at"` +} + +func loadRole(ctx context.Context, id int64) (*roleRecord, error) { + var role *roleRecord + err := dao.SysAdminRoles.Ctx(ctx).Where("id", id).Scan(&role) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if role == nil { + return nil, lcommon.NewNotFoundError("角色") + } + return role, nil +} + +func loadRolePermissions(ctx context.Context, roleID int64) ([]string, error) { + var rows []struct { + PermissionPoint string `json:"permission_point"` + } + err := dao.SysAdminRolePermissions.Ctx(ctx). + Where("role_id", roleID). + Fields("permission_point"). + OrderAsc("permission_point"). + Scan(&rows) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + return nil, err + } + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.PermissionPoint + } + return out, nil +} + +// replaceRolePermissions 全量覆盖角色权限(调用方负责事务与缓存失效)。 +func replaceRolePermissions(ctx context.Context, roleID int64, perms []string) error { + if _, err := dao.SysAdminRolePermissions.Ctx(ctx). + Where("role_id", roleID).Delete(); err != nil { + return err + } + if len(perms) == 0 { + return nil + } + data := make([]do.SysAdminRolePermissions, len(perms)) + for i, p := range perms { + data[i] = do.SysAdminRolePermissions{RoleId: roleID, PermissionPoint: p} + } + _, err := dao.SysAdminRolePermissions.Ctx(ctx).Data(data).Insert() + return err +} + +// sanitizeGrantedPermissions 校验并规整待授予的权限点: +// 1. 去重、去空、排序,保证同一份权限集合在库里的表示是稳定的 +// 2. 拒绝未定义的权限点 —— 写错一个字(channel:veiw)不会有编译期报错, +// 但会让该模块对该角色永久失效,且排查时毫无线索 +// 3. 防自我提权:非超管不得授予自己当前不具备的权限点 +// +// 第 3 条是兜底。按预置矩阵只有超管拥有 user:edit,但自定义角色可能把 user:* 授予他人, +// 那时若不拦截,一个有 user:edit 的账号就能给自己加满权限,角色体系形同虚设。 +func sanitizeGrantedPermissions(ctx context.Context, perms []string) ([]string, error) { + valid := buildValidPermissionSet() + + seen := make(map[string]bool, len(perms)) + out := make([]string, 0, len(perms)) + for _, p := range perms { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + continue + } + if !valid[p] { + return nil, lcommon.NewBadRequestError("无效的权限点:" + p) + } + seen[p] = true + out = append(out, p) + } + sort.Strings(out) + + operatorRole := lcommon.GetCtxUserRole(ctx) + if operatorRole == "super_admin" { + return out, nil + } + + operatorID := lcommon.GetCtxUserID(ctx) + if operatorID == 0 { + // 没有调用者身份(如后台任务)时不做提权判断,交由上层接口鉴权把关 + return out, nil + } + own, err := GetEffectivePermissions(ctx, operatorID, operatorRole) + if err != nil { + return nil, err + } + ownSet := make(map[string]bool, len(own)) + for _, p := range own { + ownSet[p] = true + } + for _, p := range out { + if !ownSet[p] { + return nil, lcommon.NewBadRequestError("不能授予自己不具备的权限:" + p) + } + } + return out, nil +} diff --git a/internal/logic/admin/role_test.go b/internal/logic/admin/role_test.go new file mode 100644 index 0000000..3d56a8d --- /dev/null +++ b/internal/logic/admin/role_test.go @@ -0,0 +1,361 @@ +package admin + +import ( + "os" + "regexp" + "sort" + "strings" + "testing" +) + +// ───────────────────────────────────────────────────────────────────────────── +// 档位模型 +// ───────────────────────────────────────────────────────────────────────────── + +// TestTierIsSuperset 高档位必须包含低档位的全部权限点。 +// 档位是「递进」语义,若高档漏了低档的某个权限,用户从「操作」升到「完全」反而会丢权限。 +func TestTierIsSuperset(t *testing.T) { + for module, defs := range tierPermissions { + read, hasRead := defs[TierRead] + operate, hasOperate := defs[TierOperate] + full, hasFull := defs[TierFull] + + if hasRead && hasOperate { + assertSubset(t, module, TierRead, read, TierOperate, operate) + } + if hasOperate && hasFull { + assertSubset(t, module, TierOperate, operate, TierFull, full) + } + if hasRead && hasFull { + assertSubset(t, module, TierRead, read, TierFull, full) + } + } +} + +func assertSubset(t *testing.T, module, loTier string, lo []string, hiTier string, hi []string) { + t.Helper() + set := make(map[string]bool, len(hi)) + for _, p := range hi { + set[p] = true + } + for _, p := range lo { + if !set[p] { + t.Errorf("模块 %s:%s 档的 %q 未包含在 %s 档中,升档会丢权限", module, loTier, p, hiTier) + } + } +} + +// TestTierPermissionsAreDefined 档位映射表里的权限点必须真实存在。 +// 写错一个字不会有编译期报错,但会让该档位永久失效。 +func TestTierPermissionsAreDefined(t *testing.T) { + valid := buildValidPermissionSet() + for module, defs := range tierPermissions { + for tier, perms := range defs { + for _, p := range perms { + if !valid[p] { + t.Errorf("模块 %s 的 %s 档引用了未定义的权限点 %q", module, tier, p) + } + if !strings.HasPrefix(p, module+":") { + t.Errorf("模块 %s 的 %s 档混入了其他模块的权限点 %q", module, tier, p) + } + } + } + } +} + +// TestEveryPermissionGroupHasTiers 每个权限点分组都要有对应的档位定义, +// 否则该模块在角色配置界面上无法配置(只能走高级模式),等于遗漏。 +func TestEveryPermissionGroupHasTiers(t *testing.T) { + for _, grp := range predefinedPermissionGroups { + if _, ok := tierPermissions[grp.Name]; !ok { + t.Errorf("权限分组 %q 没有档位定义,角色界面将无法配置该模块", grp.Name) + } + } +} + +// TestModuleTiersFoldsEquivalent 等价档位必须折叠,界面不渲染无意义的选项。 +func TestModuleTiersFoldsEquivalent(t *testing.T) { + cases := map[string][]string{ + "dashboard": {TierNone, TierRead}, // 只有 view,无操作/完全之分 + "promo": {TierNone, TierRead, TierOperate}, // 没有删除动作,无「完全」档 + "order": {TierNone, TierRead, TierFull}, // 只读 → 含退款,没有中间档 + "tenant": {TierNone, TierRead, TierOperate, TierFull}, // 三档齐全 + } + for module, want := range cases { + got := ModuleTiers(module) + if !equalStrings(got, want) { + t.Errorf("ModuleTiers(%q) = %v, want %v", module, got, want) + } + } +} + +// TestTierRoundTrip 档位 → 权限点 → 档位 必须能原样还原, +// 否则界面在「保存后重新打开」时会把用户选的档位显示成别的档。 +func TestTierRoundTrip(t *testing.T) { + for module := range tierPermissions { + for _, tier := range ModuleTiers(module) { + perms := TierPermissions(module, tier) + if got := TierForPermissions(module, perms); got != tier { + t.Errorf("模块 %s:档位 %s 的权限集反推为 %q,未能还原", module, tier, got) + } + } + } +} + +// TestTierForPermissionsCustom 不匹配任何档位的权限组合应返回空串(界面显示「自定义」)。 +func TestTierForPermissionsCustom(t *testing.T) { + // 只给 delete 不给 view:不属于任何档位 + if got := TierForPermissions("model", []string{"model:delete"}); got != "" { + t.Errorf("自定义权限组合应返回空串,实际 %q", got) + } + // 无该模块任何权限 = none + if got := TierForPermissions("model", []string{"tenant:view"}); got != TierNone { + t.Errorf("无该模块权限应返回 none,实际 %q", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 预置角色:锁住安全关键的取舍 +// ───────────────────────────────────────────────────────────────────────────── + +// TestBuiltinRoleDefaultsAreValid 预置角色的权限点必须真实存在且无重复。 +func TestBuiltinRoleDefaultsAreValid(t *testing.T) { + valid := buildValidPermissionSet() + for code, perms := range builtinRoleDefaults { + seen := make(map[string]bool, len(perms)) + for _, p := range perms { + if !valid[p] { + t.Errorf("预置角色 %s 引用了未定义的权限点 %q", code, p) + } + if seen[p] { + t.Errorf("预置角色 %s 重复声明权限点 %q", code, p) + } + seen[p] = true + } + } +} + +// TestBuiltinRoleBoundaries 锁定三个角色的分界线。 +// +// 这些不是实现细节而是安全决策:改动它们意味着改变谁能碰钱、谁能改系统、谁能提权。 +// 本测试让这类改动必须是显式的。 +func TestBuiltinRoleBoundaries(t *testing.T) { + has := func(code, perm string) bool { + for _, p := range builtinRoleDefaults[code] { + if p == perm { + return true + } + } + return false + } + + // 地基:能改权限的人只能是超级管理员。 + // 任何预置角色拿到 user:* 都意味着它能给自己加满权限,角色体系随即失效。 + for code := range builtinRoleDefaults { + for _, perm := range []string{"user:view", "user:create", "user:edit", "user:delete"} { + if has(code, perm) { + t.Errorf("预置角色 %s 不应拥有账号管理权限 %s —— 能改权限的人只能是超级管理员", code, perm) + } + } + } + + // 插件是代码执行面、敏感日志是隐私面,仅限超管(在线自更新无权限点,超管专属) + for code := range builtinRoleDefaults { + for _, perm := range []string{"system:plugin", "audit:read_sensitive"} { + if has(code, perm) { + t.Errorf("预置角色 %s 不应拥有 %s(仅限超级管理员)", code, perm) + } + } + } + + // 管理员 = 能碰钱与系统配置;这正是它与运营分开存在的理由 + for _, perm := range []string{"billing:refund", "order:refund", "system:edit"} { + if !has("admin", perm) { + t.Errorf("管理员应拥有 %s(去掉退款与系统设置,管理员与运营就没有分开的必要)", perm) + } + } + + // 运营 = 业务配置全权,但碰不了钱 + for _, perm := range []string{"channel:edit", "channel:delete", "model:edit", "plan:edit"} { + if !has("operator", perm) { + t.Errorf("运营应拥有 %s(平台运营的本职工作就是配渠道配模型)", perm) + } + } + for _, perm := range []string{"billing:refund", "order:refund", "system:edit", "system:view", "audit:view"} { + if has("operator", perm) { + t.Errorf("运营不应拥有 %s", perm) + } + } + // 租户停在「操作」档:能停服(可撤销),不能删除与销户(不可逆) + if !has("operator", "tenant:suspend") { + t.Error("运营应拥有 tenant:suspend(欠费/违规停服是日常运营手段)") + } + for _, perm := range []string{"tenant:delete", "tenant:close"} { + if has("operator", perm) { + t.Errorf("运营不应拥有 %s(不可逆操作留给管理员)", perm) + } + } + + // 技术支持 = 除财务面外全站只读,唯一写权限是工单 + for _, perm := range []string{"support:reply", "support:edit", "audit:view", "monitor:view", "channel:view"} { + if !has("support", perm) { + t.Errorf("技术支持应拥有 %s(排查问题所需)", perm) + } + } + for _, perm := range []string{"billing:view", "order:view", "promo:view", "redemption:view", "invoice:view"} { + if has("support", perm) { + t.Errorf("技术支持不应看到财务数据 %s", perm) + } + } + // 除 support:* 外不得有任何写权限 + for _, p := range builtinRoleDefaults["support"] { + if strings.HasPrefix(p, "support:") { + continue + } + if !strings.HasSuffix(p, ":view") { + t.Errorf("技术支持除工单外应全部只读,但拥有写权限 %s", p) + } + } +} + +// TestBuiltinRoleDefaultsMatchMigration 保证 Go 侧的「恢复默认权限」与迁移种子一致。 +// +// 两处各存一份权限清单是必要的(迁移是 SQL、恢复默认是 Go),但漂移后果隐蔽: +// 新装部署拿到迁移的版本,点了「恢复默认」却变成 Go 的版本,同一个角色在不同部署里权限不同。 +func TestBuiltinRoleDefaultsMatchMigration(t *testing.T) { + raw, err := os.ReadFile("../../../migrations/000020_v0_2_17_version.sql") + if err != nil { + t.Fatalf("读取迁移脚本失败: %v", err) + } + sql := string(raw) + + // 匹配 unnest(ARRAY[ ... ]) ... WHERE r.code = 'xxx' + blockRe := regexp.MustCompile(`(?s)unnest\(ARRAY\[(.*?)\]\).*?r\.code = '([a-z_]+)'`) + permRe := regexp.MustCompile(`'([a-z_]+:[a-z_]+)'`) + + found := make(map[string][]string) + for _, m := range blockRe.FindAllStringSubmatch(sql, -1) { + body, code := m[1], m[2] + var perms []string + for _, pm := range permRe.FindAllStringSubmatch(body, -1) { + perms = append(perms, pm[1]) + } + found[code] = perms + } + + if len(found) != len(builtinRoleDefaults) { + t.Fatalf("迁移脚本里解析到 %d 个角色种子,Go 侧定义了 %d 个", + len(found), len(builtinRoleDefaults)) + } + + for code, want := range builtinRoleDefaults { + got, ok := found[code] + if !ok { + t.Errorf("迁移脚本缺少角色 %s 的权限种子", code) + continue + } + if !equalStringSets(got, want) { + t.Errorf("角色 %s 的权限在迁移脚本与 Go 定义之间不一致:\n 仅迁移有: %v\n 仅 Go 有: %v", + code, difference(got, want), difference(want, got)) + } + } +} + +// TestDangerousPermissionsAreDefined 高危权限清单必须引用真实存在的权限点。 +func TestDangerousPermissionsAreDefined(t *testing.T) { + valid := buildValidPermissionSet() + for p := range dangerousPermissions { + if !valid[p] { + t.Errorf("高危权限清单引用了未定义的权限点 %q", p) + } + } + // 资金与提权类必须在清单内 —— 它们是二次确认的核心对象 + for _, p := range []string{"billing:refund", "order:refund", "user:edit", "system:edit"} { + if _, ok := IsDangerousPermission(p); !ok { + t.Errorf("%s 应被标记为高危权限", p) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 角色标识校验 +// ───────────────────────────────────────────────────────────────────────────── + +func TestRoleCodeValidation(t *testing.T) { + valid := []string{"operator", "sales", "ops_l2", "support2", "ab"} + invalid := []string{ + "", // 空 + "a", // 太短 + "Operator", // 大写 + "2ops", // 数字开头 + "ops-l2", // 连字符 + "ops l2", // 空格 + "客服", // 中文 + strings.Repeat("a", 51), // 超长 + } + for _, c := range valid { + if !roleCodeRegexp.MatchString(c) { + t.Errorf("角色标识 %q 应被接受", c) + } + } + for _, c := range invalid { + if roleCodeRegexp.MatchString(c) { + t.Errorf("角色标识 %q 应被拒绝", c) + } + } + // super_admin 形式上合法,但属于保留字,由业务层单独拦截 + if !roleCodeRegexp.MatchString(reservedRoleCode) { + t.Error("保留字 super_admin 形式上应合法(由业务层按保留字拦截,而非靠正则)") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 辅助 +// ───────────────────────────────────────────────────────────────────────────── + +func TestSamePermissionSet(t *testing.T) { + if !samePermissionSet([]string{"a", "b"}, []string{"b", "a"}) { + t.Error("顺序不同的相同集合应判定为相等") + } + if samePermissionSet([]string{"a"}, []string{"a", "b"}) { + t.Error("长度不同的集合应判定为不等") + } + if samePermissionSet(nil, []string{"a"}) { + t.Error("nil 与非空集合应判定为不等") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalStringSets(a, b []string) bool { + sa := append([]string(nil), a...) + sb := append([]string(nil), b...) + sort.Strings(sa) + sort.Strings(sb) + return equalStrings(sa, sb) +} + +// difference 返回在 a 中但不在 b 中的元素。 +func difference(a, b []string) []string { + set := make(map[string]bool, len(b)) + for _, s := range b { + set[s] = true + } + var out []string + for _, s := range a { + if !set[s] { + out = append(out, s) + } + } + return out +} diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 5586130..c5f00f0 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -69,6 +69,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1. if err != nil { return nil, gerror.Wrapf(err, "create session") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) // Generate token pair tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti) @@ -98,6 +99,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1. res.User.Username = user.Username res.User.DisplayName = user.DisplayName res.User.Role = user.Role + res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role) return res, nil } @@ -254,6 +256,8 @@ func (s *sAdmin) LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq) type adminUserBrief struct { Username string DisplayName string + // Role 用于会话列表标记超管会话(前端隐藏撤销入口),其他场景可为空 + Role string } func adminUserIdsByUsername(ctx context.Context, keyword string) ([]int64, error) { diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index 31ae47a..e1be102 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -86,6 +86,8 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate // validateCrossFieldSettings validates interdependent settings within a category. func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error { switch category { + case "general": + return s.validateGeneralSettings(ctx, values) case "security": return s.validateSecuritySettings(ctx, values) case "channel": @@ -95,6 +97,20 @@ func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string } } +// validateGeneralSettings 校验基础配置项:维护时长必须为 Go 时长格式(如 2h、30m、1h30m)。 +// API 维护中间件按该格式解析 Retry-After,格式非法会静默回退 300s,故在保存入口拦截。 +func (s *sAdmin) validateGeneralSettings(_ context.Context, values map[string]string) error { + durationStr, ok := values["maintenance_duration"] + if !ok || strings.TrimSpace(durationStr) == "" { + return nil + } + d, err := time.ParseDuration(strings.TrimSpace(durationStr)) + if err != nil || d <= 0 { + return common.NewBadRequestError("维护时长格式不正确,请使用如 2h、30m、1h30m 的时长格式") + } + return nil +} + // validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。 func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error { enabled, ok := values["turnstile_enabled"] diff --git a/internal/logic/admin/support_pending.go b/internal/logic/admin/support_pending.go new file mode 100644 index 0000000..8f12224 --- /dev/null +++ b/internal/logic/admin/support_pending.go @@ -0,0 +1,103 @@ +package admin + +import ( + "context" + "encoding/json" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +// ============================================================ +// 顶栏铃铛:客服支持待办聚合 +// +// 与工作台(workbench.go)的分工:工作台回答「哪些事异常了」, +// 刻意不报工单总数避免永久红点;铃铛回答「有多少客户在等回复」, +// 报全量 pending —— 处理完自动归零,不需要任何人点「完成」。 +// 待办不落库、实时派生,整份结果进 Redis 缓存 30s(与工作台同模式), +// 菜单红点轮询与铃铛各自独立缓存键,互不挤兑。 +// ============================================================ + +const ( + supportPendingCacheKey = "admin:support_pending:v1" + supportPendingCacheTTL = 30 * time.Second +) + +// supportPendingCollected 一轮聚合的完整结果(进缓存的就是它)。 +type supportPendingCollected struct { + Tickets int `json:"tickets"` + TicketsOverdue int `json:"tickets_overdue"` + Feedbacks int `json:"feedbacks"` + FeedbacksWait string `json:"feedbacks_wait"` + GeneratedAt string `json:"generated_at"` +} + +// GetSupportPendingSummary 顶栏铃铛待办计数。 +func (s *sAdmin) GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) { + col := s.collectSupportPendingCached(ctx) + return &v1.AdminSupportPendingSummaryRes{ + Tickets: col.Tickets, + TicketsOverdue: col.TicketsOverdue, + Feedbacks: col.Feedbacks, + FeedbacksWait: col.FeedbacksWait, + Total: col.Tickets + col.Feedbacks, + GeneratedAt: col.GeneratedAt, + }, nil +} + +// collectSupportPendingCached 读缓存,未命中则重新聚合。 +// 缓存读写失败一律降级为直接查询 —— 铃铛不能因为 Redis 抖动就消失。 +func (s *sAdmin) collectSupportPendingCached(ctx context.Context) *supportPendingCollected { + if v, err := g.Redis().Get(ctx, supportPendingCacheKey); err == nil && !v.IsEmpty() { + var col supportPendingCollected + if json.Unmarshal(v.Bytes(), &col) == nil { + return &col + } + } + + col := s.collectSupportPending(ctx) + + if b, err := json.Marshal(col); err == nil { + if _, err := g.Redis().Do(ctx, "SET", supportPendingCacheKey, b, "EX", int(supportPendingCacheTTL.Seconds())); err != nil { + g.Log().Warningf(ctx, "support pending: 写缓存失败: %v", err) + } + } + return col +} + +// collectSupportPending 两个独立查询,任一失败只丢自己那部分(记 warning), +// 不拖垮整个铃铛 —— 半个铃铛也好过没有铃铛。 +func (s *sAdmin) collectSupportPending(ctx context.Context) *supportPendingCollected { + col := &supportPendingCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)} + + // 工单:pending + reopened 全量。超 SLA 口径与工作台 collector 一致: + // 仅 pending 计超时(reopened 说明已有人处理过,不算「未首响」)。 + rows, err := g.DB().Ctx(ctx).Query(ctx, + `SELECT COUNT(*) AS cnt, + COUNT(*) FILTER (WHERE status = 'pending' AND created_at <= ?) AS overdue + FROM spt_tickets + WHERE status IN ('pending', 'reopened')`, gtime.Now().Add(-wbTicketSLAHours*time.Hour)) + if err != nil { + g.Log().Warningf(ctx, "support pending: 查询待处理工单失败: %v", err) + } else if len(rows) > 0 { + col.Tickets = rows[0]["cnt"].Int() + col.TicketsOverdue = rows[0]["overdue"].Int() + } + + // 反馈:pending 全量 + 最早一条的等待时长(反馈无 SLA 概念,只展示积压程度)。 + fbRows, err := g.DB().Ctx(ctx).Query(ctx, + `SELECT COUNT(*) AS cnt, MIN(created_at) AS oldest + FROM spt_feedbacks WHERE status = 'pending'`) + if err != nil { + g.Log().Warningf(ctx, "support pending: 查询待处理反馈失败: %v", err) + } else if len(fbRows) > 0 { + col.Feedbacks = fbRows[0]["cnt"].Int() + if oldest := fbRows[0]["oldest"].GTime(); oldest != nil { + col.FeedbacksWait = humanizeSince(oldest) + } + } + return col +} diff --git a/internal/logic/admin/task_management.go b/internal/logic/admin/task_management.go index 39f9d00..693dc68 100644 --- a/internal/logic/admin/task_management.go +++ b/internal/logic/admin/task_management.go @@ -2,6 +2,7 @@ package admin import ( "context" + "encoding/json" "time" "github.com/gogf/gf/v2/errors/gcode" @@ -132,6 +133,8 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas ResultUrl string `json:"result_url"` TenantId int64 `json:"tenant_id"` UserId int64 `json:"user_id"` + PrivateData string `json:"private_data"` + RequestId string `json:"request_id"` SubmitTime *gtime.Time `json:"submit_time"` StartTime *gtime.Time `json:"start_time"` FinishTime *gtime.Time `json:"finish_time"` @@ -153,6 +156,7 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas Status: task.Status, Progress: task.Progress, ModelName: task.ModelName, + UpstreamModel: task.UpstreamModel, FailReason: task.FailReason, PreDeductAmount: task.PreDeductAmount, ActualCost: task.ActualCost, @@ -160,15 +164,32 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas ResultURL: task.ResultUrl, TenantID: task.TenantId, UserID: task.UserId, + RequestID: task.RequestId, CreatedAt: task.CreatedAt.Format("Y-m-d H:i:s"), } if task.SubmitTime != nil { item.SubmitTime = task.SubmitTime.Format("Y-m-d H:i:s") } + if task.StartTime != nil { + item.StartTime = task.StartTime.Format("Y-m-d H:i:s") + } if task.FinishTime != nil { item.FinishTime = task.FinishTime.Format("Y-m-d H:i:s") } + // 从 private_data 提取上游排障标识:仅取 upstream_task_id / upstream_request_id 两个键, + // 计费上下文等其余内部数据不外露(同步图片任务无上游任务 ID,两字段为空) + if task.PrivateData != "" { + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + UpstreamRequestID string `json:"upstream_request_id"` + } + if json.Unmarshal([]byte(task.PrivateData), &pd) == nil { + item.UpstreamTaskID = pd.UpstreamTaskID + item.UpstreamRequestID = pd.UpstreamRequestID + } + } + // 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL // (规避 result_url 24h 预签名过期)。非图片/上游直链任务查不到文件记录,保持原 result_url。 if thumb, orig := common.TaskResultImageURLs(ctx, task.TenantId, task.PublicTaskId, 600); thumb != "" { diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 38308f8..81290c5 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strconv" "strings" + "unicode/utf8" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" @@ -22,6 +23,64 @@ import ( "github.com/qianfree/team-api/internal/utility/export" ) +// 租户标签约束:数量与单项长度上限(字符数,非字节数) +const ( + tenantTagsMaxCount = 10 + tenantTagsMaxRunes = 30 + tenantRemarkMaxRunes = 1000 +) + +// marshalTagFilter 把单个标签序列化为 JSONB 包含查询参数(tags @> '["vip"]')。 +// 序列化不会失败(输入为字符串),错误时返回空数组条件(匹配不到任何行,安全降级)。 +func marshalTagFilter(tag string) string { + b, err := json.Marshal([]string{strings.TrimSpace(tag)}) + if err != nil { + return "[]" + } + return string(b) +} + +// parseTenantTags 解析租户标签 JSONB(空/非法输入返回空切片,不报错——展示路径容错) +func parseTenantTags(raw string) []string { + if raw == "" { + return []string{} + } + var tags []string + if err := json.Unmarshal([]byte(raw), &tags); err != nil { + return []string{} + } + return tags +} + +// marshalTenantTags 校验并序列化租户标签为 JSONB 字符串: +// 去首尾空白、跳过空项、去重,≤ tenantTagsMaxCount 个、每项 ≤ tenantTagsMaxRunes 字符。 +func marshalTenantTags(tags []string) (string, error) { + cleaned := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if utf8.RuneCountInString(tag) > tenantTagsMaxRunes { + return "", common.NewBadRequestError("单个标签最长 30 字符") + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + cleaned = append(cleaned, tag) + } + if len(cleaned) > tenantTagsMaxCount { + return "", common.NewBadRequestError("标签数量最多 10 个") + } + b, err := json.Marshal(cleaned) + if err != nil { + return "", err + } + return string(b), nil +} + // TenantSelect returns a lightweight paginated tenant list for dropdown selectors. func (s *sAdmin) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) @@ -125,6 +184,19 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 maxConcurrencyVal = req.MaxConcurrency } + // 标签与备注:可选字段,校验规则与更新接口一致;空值落库为列默认([] / '') + tagsJSON, err := marshalTenantTags(req.Tags) + if err != nil { + return nil, err + } + remarkVal := "" + if req.Remark != nil { + remarkVal = strings.TrimSpace(*req.Remark) + if utf8.RuneCountInString(remarkVal) > tenantRemarkMaxRunes { + return nil, common.NewBadRequestError("备注最长 1000 字符") + } + } + var tenantID int64 err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { @@ -135,6 +207,8 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 MaxConcurrency: maxConcurrencyVal, Level: 1, Settings: "{}", + Tags: tagsJSON, + Remark: remarkVal, }).Insert() if err != nil { return gerror.Wrapf(err, "create tenant") @@ -217,10 +291,12 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) ( ownerNames map[int64]string, memberCounts map[int64]int, walletBalances map[int64]string, + walletTotalConsumed map[int64]string, ) { ownerNames = make(map[int64]string, len(ownerIDs)) memberCounts = make(map[int64]int, len(tenantIDs)) walletBalances = make(map[int64]string, len(tenantIDs)) + walletTotalConsumed = make(map[int64]string, len(tenantIDs)) // owner 名称:按 owner_user_id 批量取 if len(ownerIDs) > 0 { @@ -246,15 +322,17 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) ( memberCounts[c.TenantID] = c.Count } - // 钱包余额:按 tenant_id 批量取 + // 钱包余额 / 累计消费:按 tenant_id 批量取(DB 物化副本,累计消费随物化器 ≤5s 滞后) var wallets []struct { - TenantID int64 `json:"tenant_id"` - Balance string `json:"balance"` + TenantID int64 `json:"tenant_id"` + Balance string `json:"balance"` + TotalConsumed string `json:"total_consumed"` } - _ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance"). + _ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance, total_consumed"). WhereIn("tenant_id", tenantIDs).Scan(&wallets) for _, w := range wallets { walletBalances[w.TenantID] = w.Balance + walletTotalConsumed[w.TenantID] = w.TotalConsumed } } @@ -274,6 +352,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } total, err := m.Count() if err != nil { @@ -289,21 +373,32 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } + // 扫描结构体必须带 orm 标签:gf 的 Scan 会用「结构体字段名」自动生成 SELECT 字段列表, + // 无 orm 标签时靠运行时查表结构(TableFields)做 Id→id 映射;该元数据查询失败会被框架静默吞掉, + // 帕斯卡字段名将原样进 SQL,PostgreSQL 引号列名大小写敏感,报 column "Id" does not exist。 var tenants []struct { - Id int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - LogoURL string `json:"logo_url"` - OwnerUserID int64 `json:"owner_user_id"` - Status string `json:"status"` - MaxMembers *int `json:"max_members"` - MaxConcurrency *int `json:"max_concurrency"` - DefaultChannelScope string `json:"default_channel_scope"` - Settings string `json:"settings"` - Level int `json:"level"` - CreatedAt *gtime.Time `json:"created_at"` - UpdatedAt *gtime.Time `json:"updated_at"` + Id int64 `json:"id" orm:"id"` + Name string `json:"name" orm:"name"` + Code string `json:"code" orm:"code"` + LogoURL string `json:"logo_url" orm:"logo_url"` + OwnerUserID int64 `json:"owner_user_id" orm:"owner_user_id"` + Status string `json:"status" orm:"status"` + MaxMembers *int `json:"max_members" orm:"max_members"` + MaxConcurrency *int `json:"max_concurrency" orm:"max_concurrency"` + DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope"` + Settings string `json:"settings" orm:"settings"` + Level int `json:"level" orm:"level"` + Tags string `json:"tags" orm:"tags"` + Remark string `json:"remark" orm:"remark"` + CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"` } err = m.OrderDesc("id"). Page(page, pageSize). @@ -333,7 +428,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te tenantIDs[i] = t.Id ownerIDs[i] = t.OwnerUserID } - ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) + ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) for i, t := range tenants { item := v1.TenantItem{ @@ -348,6 +443,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te DefaultChannelScope: t.DefaultChannelScope, Level: t.Level, LevelName: levelNameMap[t.Level], + Tags: parseTenantTags(t.Tags), + Remark: t.Remark, CreatedAt: t.CreatedAt.String(), UpdatedAt: t.UpdatedAt.String(), } @@ -368,7 +465,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te item.EffectiveMaxConcurrency = 0 } - // 从批量结果中取 owner 名称 / 成员数 / 钱包余额 + // 从批量结果中取 owner 名称 / 成员数 / 钱包余额 / 累计消费 item.OwnerName = ownerNameMap[t.OwnerUserID] item.MemberCount = memberCountMap[t.Id] if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" { @@ -376,6 +473,11 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te } else { item.WalletBalance = "0" } + if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" { + item.TotalConsumed = tc + } else { + item.TotalConsumed = "0" + } items[i] = item } @@ -402,6 +504,8 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan DefaultChannelScope string `json:"default_channel_scope"` Settings string `json:"settings"` Level int `json:"level"` + Tags string `json:"tags"` + Remark string `json:"remark"` CreatedAt *gtime.Time `json:"created_at"` UpdatedAt *gtime.Time `json:"updated_at"` } @@ -425,19 +529,27 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan memberCount, _ := dao.TntUsers.Ctx(ctx). Where("tenant_id", req.Id).Count() - // Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底 + // Get wallet balance / 累计消费:优先读 Redis 权威值(调整余额/入账/结算后详情页立即可见),DB 物化值兜底 walletBalance := "0" + totalConsumed := "0" if w, err := billing.GetWallet(ctx, req.Id); err == nil { walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64) + totalConsumed = strconv.FormatFloat(w.TotalConsumed, 'f', -1, 64) } else { var wallet *struct { - Balance string `json:"balance"` + Balance string `json:"balance"` + TotalConsumed string `json:"total_consumed"` } _ = dao.BilWallets.Ctx(ctx). Where("tenant_id", req.Id).Scan(&wallet) - if wallet != nil && wallet.Balance != "" { - walletBalance = wallet.Balance + if wallet != nil { + if wallet.Balance != "" { + walletBalance = wallet.Balance + } + if wallet.TotalConsumed != "" { + totalConsumed = wallet.TotalConsumed + } } } ownerName := "" @@ -483,6 +595,9 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan DefaultChannelScope: tenant.DefaultChannelScope, MemberCount: memberCount, WalletBalance: walletBalance, + Tags: parseTenantTags(tenant.Tags), + Remark: tenant.Remark, + TotalConsumed: totalConsumed, Level: tenant.Level, LevelName: levelName, CreatedAt: tenant.CreatedAt.String(), @@ -553,6 +668,22 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1 data.Level = *req.Level } + // 标签:nil 表示未传(不更新);空数组表示清空(序列化为 []) + if req.Tags != nil { + tagsJSON, err := marshalTenantTags(req.Tags) + if err != nil { + return nil, err + } + data.Tags = tagsJSON + } + if req.Remark != nil { + remark := strings.TrimSpace(*req.Remark) + if utf8.RuneCountInString(remark) > tenantRemarkMaxRunes { + return nil, common.NewBadRequestError("备注最长 1000 字符") + } + data.Remark = remark + } + _, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(data) if err != nil { return nil, err @@ -590,8 +721,11 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v {Field: "code", Header: "代码"}, {Field: "owner_name", Header: "所有者"}, {Field: "status", Header: "状态"}, + {Field: "tags", Header: "标签"}, + {Field: "remark", Header: "备注"}, {Field: "member_count", Header: "成员数"}, {Field: "wallet_balance", Header: "钱包余额"}, + {Field: "total_consumed", Header: "累计消费"}, {Field: "created_at", Header: "创建时间"}, } @@ -612,40 +746,55 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } var batch []struct { Id int64 `json:"id"` Name string `json:"name"` Code string `json:"code"` OwnerUserID int64 `json:"owner_user_id"` Status string `json:"status"` + Tags string `json:"tags"` + Remark string `json:"remark"` CreatedAt *gtime.Time `json:"created_at"` } - if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil { + if err := m.Fields("id, name, code, owner_user_id, status, tags, remark, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil { return } - // 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1) + // 批量查询本页的 owner 名称 / 成员数 / 钱包余额 / 累计消费(避免逐行 N+1) tenantIDs := make([]int64, len(batch)) ownerIDs := make([]int64, len(batch)) for i, t := range batch { tenantIDs[i] = t.Id ownerIDs[i] = t.OwnerUserID } - ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) + ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) for _, t := range batch { walletBalance := "0" if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" { walletBalance = bal } + totalConsumed := "0" + if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" { + totalConsumed = tc + } row := map[string]any{ "id": t.Id, "name": t.Name, "code": t.Code, "owner_name": ownerNameMap[t.OwnerUserID], "status": t.Status, + "tags": strings.Join(parseTenantTags(t.Tags), ","), + "remark": t.Remark, "member_count": memberCountMap[t.Id], "wallet_balance": walletBalance, + "total_consumed": totalConsumed, "created_at": t.CreatedAt.String(), } if !yield(row) { diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index cfdc60b..c794913 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/shopspring/decimal" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" @@ -13,6 +13,7 @@ import ( tenantLogic "github.com/qianfree/team-api/internal/logic/common" relay "github.com/qianfree/team-api/internal/logic/relay" do "github.com/qianfree/team-api/internal/model/do" + "github.com/shopspring/decimal" ) // ListTenantModels 列出租户已分配的模型 @@ -190,6 +191,20 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat data.Enabled = *req.Enabled } if req.BillingMode != nil { + // 特殊计费方案模型不允许租户级 billing_mode 覆盖:计费模式由平台方案决定(special), + // 租户改模式只会造成日志标签与计费口径错位(读取侧 GetModelPriceAt 同口径忽略覆盖)。 + // 置空(恢复平台默认)不受限 + if *req.BillingMode != "" { + var pricingRow struct { + Pricing string `json:"pricing"` + } + if err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Fields("pricing").Scan(&pricingRow); err != nil { + return nil, gerror.Wrap(err, "查询模型定价") + } + if blob := billing.ParsePricingBlob(pricingRow.Pricing); blob != nil && blob.Scheme != "" { + return nil, gerror.New("该模型使用特殊计费方案,计费模式由平台方案决定,不允许租户级覆盖") + } + } data.BillingMode = *req.BillingMode } if req.PerRequestPrice != nil { @@ -269,7 +284,7 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet // ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) { - models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "") + models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "", "") if err != nil { return nil, err } diff --git a/internal/logic/admin/tenant_tags_test.go b/internal/logic/admin/tenant_tags_test.go new file mode 100644 index 0000000..2c5184b --- /dev/null +++ b/internal/logic/admin/tenant_tags_test.go @@ -0,0 +1,66 @@ +package admin + +import ( + "testing" +) + +// TestMarshalTenantTags 标签校验与序列化:去空白、去重、跳过空项、数量与长度上限 +func TestMarshalTenantTags(t *testing.T) { + cases := []struct { + name string + input []string + want string + wantErr bool + }{ + {"普通标签", []string{"vip", "重点客户"}, `["vip","重点客户"]`, false}, + {"去首尾空白", []string{" vip ", "dev"}, `["vip","dev"]`, false}, + {"跳过空项", []string{"", " ", "vip"}, `["vip"]`, false}, + {"去重", []string{"vip", "vip", "dev"}, `["vip","dev"]`, false}, + {"空数组清空", []string{}, `[]`, false}, + {"nil 清空", nil, `[]`, false}, + {"超长单标签", []string{"一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一"}, "", true}, + {"数量超限", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, "", true}, + {"英文30字符边界", []string{"abcdefghijklmnopqrstuvwxyz0123"}, `["abcdefghijklmnopqrstuvwxyz0123"]`, false}, + {"英文31字符超限", []string{"abcdefghijklmnopqrstuvwxyz0123" + "4"}, "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := marshalTenantTags(c.input) + if c.wantErr { + if err == nil { + t.Fatalf("want error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.want { + t.Fatalf("got=%s want=%s", got, c.want) + } + }) + } +} + +// TestParseTenantTags 标签解析容错:空/非法输入返回空切片而非报错(展示路径) +func TestParseTenantTags(t *testing.T) { + if got := parseTenantTags(""); got == nil || len(got) != 0 { + t.Fatalf("empty input: want empty slice, got %v", got) + } + if got := parseTenantTags("not-json"); got == nil || len(got) != 0 { + t.Fatalf("invalid json: want empty slice, got %v", got) + } + if got := parseTenantTags(`["vip","dev"]`); len(got) != 2 || got[0] != "vip" || got[1] != "dev" { + t.Fatalf("valid json: got %v", got) + } +} + +// TestMarshalTagFilter 标签筛选参数序列化为 JSONB 包含查询条件 +func TestMarshalTagFilter(t *testing.T) { + if got := marshalTagFilter("vip"); got != `["vip"]` { + t.Fatalf("got=%s want=[\"vip\"]", got) + } + if got := marshalTagFilter(" vip "); got != `["vip"]` { + t.Fatalf("trimmed: got=%s", got) + } +} diff --git a/internal/logic/admin/ticket.go b/internal/logic/admin/ticket.go index a2206da..0f1aa0b 100644 --- a/internal/logic/admin/ticket.go +++ b/internal/logic/admin/ticket.go @@ -53,7 +53,7 @@ func (s *sAdmin) ListAllTickets(ctx context.Context, req *v1.TicketListReq) (*v1 LeftJoin("tnt_tenants tt", "spt_tickets.tenant_id = tt.id"). LeftJoin("tnt_users tu", "spt_tickets.user_id = tu.id"). LeftJoin("sys_admin_users sa", "spt_tickets.assigned_admin_id = sa.id"). - Fields("spt_tickets.*, COALESCE(tt.name, '') as tenant_name, COALESCE(tu.display_name, '') as user_display_name, COALESCE(sa.username, '') as assigned_admin_name"). + Fields("spt_tickets.*, COALESCE(tt.name, '') as tenant_name, COALESCE(tu.display_name, '') as user_display_name, COALESCE(sa.display_name, '') as assigned_admin_name"). OrderDesc("spt_tickets.created_at"). Page(page, pageSize). ScanAndCount(&rows, &total, false) @@ -102,7 +102,7 @@ func (s *sAdmin) GetTicketAdmin(ctx context.Context, req *v1.TicketGetReq) (*v1. LeftJoin("tnt_tenants tt", "spt_tickets.tenant_id = tt.id"). LeftJoin("tnt_users tu", "spt_tickets.user_id = tu.id"). LeftJoin("sys_admin_users sa", "spt_tickets.assigned_admin_id = sa.id"). - Fields("spt_tickets.*, COALESCE(tt.name, '') as tenant_name, COALESCE(tu.display_name, '') as user_display_name, COALESCE(sa.username, '') as assigned_admin_name"). + Fields("spt_tickets.*, COALESCE(tt.name, '') as tenant_name, COALESCE(tu.display_name, '') as user_display_name, COALESCE(sa.display_name, '') as assigned_admin_name"). Where("spt_tickets.id", req.Id). Scan(&ticket) if err != nil { @@ -120,7 +120,7 @@ func (s *sAdmin) GetTicketAdmin(ctx context.Context, req *v1.TicketGetReq) (*v1. err = dao.SptReplies.Ctx(ctx). LeftJoin("sys_admin_users sa", "spt_replies.user_type = 'admin' AND spt_replies.user_id = sa.id"). LeftJoin("tnt_users tu", "spt_replies.user_type = 'tenant' AND spt_replies.user_id = tu.id"). - Fields("spt_replies.*, CASE WHEN spt_replies.user_type = 'admin' THEN COALESCE(sa.username, '') ELSE COALESCE(tu.display_name, '') END as user_name"). + Fields("spt_replies.*, CASE WHEN spt_replies.user_type = 'admin' THEN COALESCE(sa.display_name, '') ELSE COALESCE(tu.display_name, '') END as user_name"). Where("spt_replies.ticket_id", req.Id). OrderAsc("spt_replies.created_at"). Scan(&replyRows) diff --git a/internal/logic/admin/usage_log_cleanup.go b/internal/logic/admin/usage_log_cleanup.go index 7ec4871..60565e6 100644 --- a/internal/logic/admin/usage_log_cleanup.go +++ b/internal/logic/admin/usage_log_cleanup.go @@ -82,14 +82,14 @@ func (s *sAdmin) UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanu } var tasks []struct { - Id int64 `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - Result string `json:"result"` - StartedAt *time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at"` - CreatedAt *time.Time `json:"created_at"` + Id int64 `json:"id" orm:"id"` + Name string `json:"name" orm:"name"` + Status string `json:"status" orm:"status"` + ErrorMessage string `json:"error_message" orm:"error_message"` + Result string `json:"result" orm:"result"` + StartedAt *time.Time `json:"started_at" orm:"started_at"` + FinishedAt *time.Time `json:"finished_at" orm:"finished_at"` + CreatedAt *time.Time `json:"created_at" orm:"created_at"` } err = m.Page(page, pageSize).Scan(&tasks) if err != nil { diff --git a/internal/logic/admin/usage_log_count.go b/internal/logic/admin/usage_log_count.go new file mode 100644 index 0000000..cb7ed08 --- /dev/null +++ b/internal/logic/admin/usage_log_count.go @@ -0,0 +1,273 @@ +package admin + +import ( + "context" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/logic/common" +) + +// 用量日志分页 total 的统计策略: +// +// bil_usage_logs 是全系统写入量最大的分区表,明细精确 COUNT 的代价与筛选范围内 +// 行数成正比,时间范围一宽,每翻一页都要重数一遍。这里按天拆段求和: +// - 完整天:bil_usage_daily 日汇总 SUM(request_count),O(天数) 而非 O(行数); +// - 头尾不完整天:明细表精确 COUNT,保持与列表完全一致的时间边界。 +// +// 口径说明(与列表对齐的两个截断): +// 1. 自动清理(usage_log_cleanup)只删 bil_usage_logs 明细、不删日汇总, +// 超过保留期的完整天在日汇总里仍保留计数而行已删——完整天窗口按保留期 +// 截断,被清掉的老区间不再计数(行已删,列表同样查不出)。 +// 2. ID/用户/Key/请求类型无法从日粒度汇总还原,出现即整体回退精确 COUNT。 +// 保留期内日汇总与明细仍可能因手动按维度清理产生少量偏差,分页 total 允许该近似。 +// +// 历史覆盖:日汇总起点(MIN(stat_date))早于查询下界时才可信;未回填的缺口段 +// 落入头段走明细 COUNT 补齐(正确但不加速),空表整体回退精确口径。 + +// usageLogCountPlan 日汇总计数的拆段计划(纯计算产物,便于单测) +type usageLogCountPlan struct { + fallback bool // true = 无法安全使用日汇总,整体回退明细精确 COUNT + + dailyFrom time.Time // 完整天窗口下界(含,当天 00:00) + dailyTo time.Time // 完整天窗口上界(含,当天 00:00) + + headFrom *time.Time // 头段下界(闭,nil=无下界);与 dailyFrom 之间为开区间 + headTo *time.Time // 头段上界(开) + hasHead bool + + tailFrom *time.Time // 尾段下界(闭) + tailTo *time.Time // 尾段上界(闭,nil=无上界) + hasTail bool +} + +// usageLogCountDailyServable 判断筛选条件能否映射到 bil_usage_daily 的聚合维度 +// (租户/渠道/模型/状态)。日汇总以天为粒度,ID/用户/Key/请求类型/上游请求ID无法还原。 +func usageLogCountDailyServable(f v1.AdminUsageLogFilter) bool { + return f.ID == 0 && f.UserID == 0 && f.ApiKeyID == 0 && f.RequestType == 0 && f.UpstreamRequestId == "" +} + +// parseUsageLogRange 解析筛选时间边界(与 buildUsageLogFilter 同口径: +// 仅日期补齐当天边界,带时间原样使用),任一侧缺省返回 nil。格式异常返回 ok=false。 +func parseUsageLogRange(f v1.AdminUsageLogFilter) (startTs, endTs *time.Time, ok bool) { + parse := func(value, expanded string) (*time.Time, bool) { + if value == "" { + return nil, true + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", expanded, time.Local) + if err != nil { + return nil, false + } + return &t, true + } + var valid bool + if startTs, valid = parse(f.StartDate, common.StartOfRange(f.StartDate)); !valid { + return nil, nil, false + } + if endTs, valid = parse(f.EndDate, common.EndOfRange(f.EndDate)); !valid { + return nil, nil, false + } + return startTs, endTs, true +} + +// buildUsageLogCountPlan 计算日汇总计数的拆段计划(纯函数,不访问数据库)。 +// 完整天 D 的判定:D 00:00 >= start 且 D 次日 00:00 <= end;日汇总只聚合到 +// 昨天(cron 每天 01:00 重算最近 3 个完整天,当天进行中的数据不聚合), +// 因此上界无论如何不超过昨天,当天与结束日的不完整部分落在尾段走明细。 +func buildUsageLogCountPlan(startTs, endTs *time.Time, now time.Time, retentionDays int, minStat time.Time) usageLogCountPlan { + dayStart := func(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + } + todayStart := dayStart(now) + + // 完整天窗口下界:起点当天 00:00 起算,起点带非零时刻则从次日起 + lower := time.Time{} + if startTs != nil { + lower = dayStart(*startTs) + if !startTs.Equal(lower) { + lower = lower.AddDate(0, 0, 1) + } + } + // 完整天窗口上界:min(昨天, 结束时间能完整覆盖的最后一天) + upper := todayStart.AddDate(0, 0, -1) + if endTs != nil { + if e := dayStart(endTs.AddDate(0, 0, -1)); e.Before(upper) { + upper = e + } + } + // 保留期截断:超期明细已被清理而日汇总仍保留计数,截掉避免多算 + if retentionDays > 0 { + if retainFrom := todayStart.AddDate(0, 0, -retentionDays); retainFrom.After(lower) { + lower = retainFrom + } + } + // 日汇总覆盖下界:历史未回填到 lower 时缺口段走明细(头段)补齐 + if minStat.After(lower) { + lower = minStat + } + + if lower.After(upper) { + return usageLogCountPlan{fallback: true} + } + + plan := usageLogCountPlan{ + dailyFrom: lower, + dailyTo: upper, + } + // 头段 [start, dailyFrom 00:00):开区间上界,避免与日汇总重叠 + plan.hasHead = startTs == nil || startTs.Before(lower) + plan.headFrom = startTs + headTo := lower + plan.headTo = &headTo + // 尾段 [dailyTo 次日 00:00, end]:闭区间,覆盖当天/结束日的进行中数据 + tailFrom := upper.AddDate(0, 0, 1) + plan.tailFrom = &tailFrom + plan.tailTo = endTs + plan.hasTail = endTs == nil || !endTs.Before(tailFrom) + return plan +} + +// usageLogDimConds 构建可映射到日汇总维度的筛选条件(租户/渠道/模型/状态), +// 返回不带 WHERE 前缀的条件串,供明细计数与日汇总查询拼接复用。 +func usageLogDimConds(f v1.AdminUsageLogFilter) (string, []any) { + var conds []string + var args []any + if f.TenantID > 0 { + conds = append(conds, "tenant_id = ?") + args = append(args, f.TenantID) + } + if f.ChannelID > 0 { + conds = append(conds, "channel_id = ?") + args = append(args, f.ChannelID) + } + if f.Model != "" { + conds = append(conds, "model_name = ?") + args = append(args, f.Model) + } + if f.Status != "" { + conds = append(conds, "status = ?") + args = append(args, f.Status) + } + return strings.Join(conds, " AND "), args +} + +// countUsageLogsExact 对明细表做与列表口径完全一致的精确 COUNT(含全部筛选维度) +func countUsageLogsExact(ctx context.Context, f v1.AdminUsageLogFilter) (int, error) { + where, args := buildUsageLogFilter(f) + val, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM bil_usage_logs u"+where, args...) + if err != nil { + return 0, err + } + return int(val.Int64()), nil +} + +// usageLogDailyMinStatDate 读取日汇总最早统计日期,用于判断历史覆盖。 +// stat_date 上有索引、表又极小,MIN 是首条索引探测;空表返回 ok=false。 +func usageLogDailyMinStatDate(ctx context.Context) (time.Time, bool, error) { + val, err := g.DB().GetValue(ctx, "SELECT MIN(stat_date) FROM bil_usage_daily") + if err != nil { + return time.Time{}, false, err + } + if val.IsNil() { + return time.Time{}, false, nil + } + t := val.Time() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()), true, nil +} + +// countUsageLogsRange 对明细表按维度条件 + 时间窗 COUNT。 +// from 闭(created_at >= from,nil=无下界);toExclusive 为 true 时 to 为开区间 +// (created_at < to),否则闭区间(created_at <= to)。 +func countUsageLogsRange(ctx context.Context, f v1.AdminUsageLogFilter, from, to *time.Time, toExclusive bool) (int64, error) { + dimConds, args := usageLogDimConds(f) + var conds []string + if dimConds != "" { + conds = append(conds, dimConds) + } + if from != nil { + conds = append(conds, "created_at >= ?") + args = append(args, *from) + } + if to != nil { + if toExclusive { + conds = append(conds, "created_at < ?") + } else { + conds = append(conds, "created_at <= ?") + } + args = append(args, *to) + } + sqlStr := "SELECT COUNT(*) FROM bil_usage_logs" + if len(conds) > 0 { + sqlStr += " WHERE " + strings.Join(conds, " AND ") + } + val, err := g.DB().GetValue(ctx, sqlStr, args...) + if err != nil { + return 0, err + } + return val.Int64(), nil +} + +// countUsageLogs 统计用量日志筛选结果总数(管理后台分页 total)。 +// 不可映射维度 / 时间参数异常 / 短窗口(≤2 天,拆段无收益)一律走精确 COUNT, +// 与原口径完全一致;大范围才进入日汇总拆段路径。 +func countUsageLogs(ctx context.Context, f v1.AdminUsageLogFilter) (int, error) { + if !usageLogCountDailyServable(f) { + return countUsageLogsExact(ctx, f) + } + startTs, endTs, ok := parseUsageLogRange(f) + if !ok { + return countUsageLogsExact(ctx, f) + } + if startTs != nil && endTs != nil && endTs.Sub(*startTs) <= 48*time.Hour { + return countUsageLogsExact(ctx, f) + } + + // 日汇总覆盖检查:空表或读取失败均回退精确口径 + minStat, hasDaily, err := usageLogDailyMinStatDate(ctx) + if err != nil || !hasDaily { + return countUsageLogsExact(ctx, f) + } + + retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days") + if retentionDays == 0 { + retentionDays = 90 + } + plan := buildUsageLogCountPlan(startTs, endTs, time.Now(), retentionDays, minStat) + if plan.fallback { + return countUsageLogsExact(ctx, f) + } + + var total int64 + // 头段:不完整起始天(或无下界时的保留期前零头) + if plan.hasHead { + n, err := countUsageLogsRange(ctx, f, plan.headFrom, plan.headTo, true) + if err != nil { + return 0, err + } + total += n + } + // 完整天:日汇总求和(维度条件与明细列一一对应) + dailySQL := "SELECT COALESCE(SUM(request_count), 0) FROM bil_usage_daily WHERE stat_date >= ? AND stat_date <= ?" + dailyArgs := []any{plan.dailyFrom.Format("2006-01-02"), plan.dailyTo.Format("2006-01-02")} + if dimConds, dimArgs := usageLogDimConds(f); dimConds != "" { + dailySQL += " AND " + dimConds + dailyArgs = append(dailyArgs, dimArgs...) + } + val, err := g.DB().GetValue(ctx, dailySQL, dailyArgs...) + if err != nil { + return 0, err + } + total += val.Int64() + // 尾段:当天及结束日的不完整部分 + if plan.hasTail { + n, err := countUsageLogsRange(ctx, f, plan.tailFrom, plan.tailTo, false) + if err != nil { + return 0, err + } + total += n + } + return int(total), nil +} diff --git a/internal/logic/admin/usage_log_count_test.go b/internal/logic/admin/usage_log_count_test.go new file mode 100644 index 0000000..81cc1e8 --- /dev/null +++ b/internal/logic/admin/usage_log_count_test.go @@ -0,0 +1,171 @@ +package admin + +import ( + "testing" + "time" + + v1 "github.com/qianfree/team-api/api/admin/v1" +) + +// tsStr 解析本地时区时间戳(与 parseUsageLogRange 同口径) +func tsStr(t *testing.T, s string) time.Time { + t.Helper() + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local) + if err != nil { + t.Fatalf("解析时间 %q 失败: %v", s, err) + } + return parsed +} + +func tsPtr(t *testing.T, s string) *time.Time { + t.Helper() + v := tsStr(t, s) + return &v +} + +func dayDate(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.Local) +} + +func TestUsageLogCountDailyServable(t *testing.T) { + base := v1.AdminUsageLogFilter{TenantID: 1, Model: "gpt-4o", ChannelID: 2, Status: "success"} + if !usageLogCountDailyServable(base) { + t.Fatal("仅维度条件时应当可走日汇总") + } + unservable := []v1.AdminUsageLogFilter{ + {ID: 100}, + {UserID: 5}, + {ApiKeyID: 7}, + {RequestType: 2}, + {TenantID: 1, UserID: 5}, + } + for _, f := range unservable { + if usageLogCountDailyServable(f) { + t.Fatalf("筛选 %+v 无法映射日汇总维度,应返回 false", f) + } + } +} + +func TestParseUsageLogRange(t *testing.T) { + // 仅日期:起点补 00:00:00,终点补 23:59:59 + start, end, ok := parseUsageLogRange(v1.AdminUsageLogFilter{StartDate: "2026-09-12", EndDate: "2026-09-19"}) + if !ok { + t.Fatal("合法日期应解析成功") + } + if !start.Equal(tsStr(t, "2026-09-12 00:00:00")) { + t.Fatalf("起点应为当天 00:00:00,实际 %v", start) + } + if !end.Equal(tsStr(t, "2026-09-19 23:59:59")) { + t.Fatalf("终点应为当天 23:59:59,实际 %v", end) + } + // 带时刻:原样使用 + start, end, ok = parseUsageLogRange(v1.AdminUsageLogFilter{StartDate: "2026-09-12 14:30:00", EndDate: "2026-09-18 09:15:00"}) + if !ok || !start.Equal(tsStr(t, "2026-09-12 14:30:00")) || !end.Equal(tsStr(t, "2026-09-18 09:15:00")) { + t.Fatalf("带时刻应原样解析,实际 %v ~ %v", start, end) + } + // 缺省:返回 nil + start, end, ok = parseUsageLogRange(v1.AdminUsageLogFilter{}) + if !ok || start != nil || end != nil { + t.Fatalf("缺省时间应返回 nil/nil/true,实际 %v %v %v", start, end, ok) + } + // 非法格式 + if _, _, ok = parseUsageLogRange(v1.AdminUsageLogFilter{StartDate: "2026/09/12"}); ok { + t.Fatal("非法格式应返回 ok=false") + } +} + +func TestBuildUsageLogCountPlan(t *testing.T) { + now := tsStr(t, "2026-09-19 15:00:00") // 今天 09-19,昨天 09-18 + retention90 := 90 // 保留期截断点 = 06-21 + + t.Run("一周范围:当天落尾段,起始天整走日汇总", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-09-12 00:00:00"), tsPtr(t, "2026-09-19 23:59:59"), now, retention90, dayDate(2026, 6, 1)) + if plan.fallback { + t.Fatal("一周范围不应回退") + } + if !plan.dailyFrom.Equal(dayDate(2026, 9, 12)) || !plan.dailyTo.Equal(dayDate(2026, 9, 18)) { + t.Fatalf("完整天窗口应为 [09-12, 09-18],实际 [%v, %v]", plan.dailyFrom, plan.dailyTo) + } + if plan.hasHead { + t.Fatal("起点为整日 00:00 时头段应为空") + } + if !plan.hasTail || !plan.tailFrom.Equal(dayDate(2026, 9, 19)) || !plan.tailTo.Equal(tsStr(t, "2026-09-19 23:59:59")) { + t.Fatalf("尾段应覆盖结束日当天,实际 from=%v to=%v", plan.tailFrom, plan.tailTo) + } + }) + + t.Run("起点带非零时刻:起始天拆进头段开区间", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-09-12 14:30:00"), nil, now, retention90, dayDate(2026, 6, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 9, 13)) { + t.Fatalf("完整天应从 09-13 起,实际 dailyFrom=%v fallback=%v", plan.dailyFrom, plan.fallback) + } + if !plan.hasHead || !plan.headFrom.Equal(tsStr(t, "2026-09-12 14:30:00")) || !plan.headTo.Equal(dayDate(2026, 9, 13)) { + t.Fatalf("头段应为 [09-12 14:30, 09-13 00:00),实际 from=%v to=%v", plan.headFrom, plan.headTo) + } + if !plan.hasTail || !plan.tailFrom.Equal(dayDate(2026, 9, 19)) || plan.tailTo != nil { + t.Fatalf("无结束时间时尾段应 [09-19, 无上界),实际 from=%v to=%v", plan.tailFrom, plan.tailTo) + } + }) + + t.Run("双侧无界:保留下界=保留期与日汇总起点的较大者", func(t *testing.T) { + // minStat(07-01) 晚于保留期截断点(06-21):下界取 minStat,之前落头段 + plan := buildUsageLogCountPlan(nil, nil, now, retention90, dayDate(2026, 7, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 7, 1)) || !plan.dailyTo.Equal(dayDate(2026, 9, 18)) { + t.Fatalf("完整天窗口应为 [07-01, 09-18],实际 [%v, %v]", plan.dailyFrom, plan.dailyTo) + } + if !plan.hasHead || plan.headFrom != nil || !plan.headTo.Equal(dayDate(2026, 7, 1)) { + t.Fatalf("头段应为 [无下界, 07-01),实际 from=%v to=%v", plan.headFrom, plan.headTo) + } + }) + + t.Run("起点早于保留期:截断点前落头段", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-05-01 00:00:00"), nil, now, retention90, dayDate(2026, 6, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 6, 21)) { + t.Fatalf("完整天下界应被保留期截到 06-21,实际 %v", plan.dailyFrom) + } + if !plan.hasHead || !plan.headFrom.Equal(dayDate(2026, 5, 1)) || !plan.headTo.Equal(dayDate(2026, 6, 21)) { + t.Fatalf("头段应为 [05-01, 06-21),实际 from=%v to=%v", plan.headFrom, plan.headTo) + } + }) + + t.Run("回填缺口:minStat 晚于保留期截断点时下界取 minStat", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-05-01 00:00:00"), nil, now, retention90, dayDate(2026, 7, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 7, 1)) { + t.Fatalf("完整天下界应取 minStat=07-01,实际 %v", plan.dailyFrom) + } + if !plan.hasHead || !plan.headTo.Equal(dayDate(2026, 7, 1)) { + t.Fatalf("缺口段应落入头段 [05-01, 07-01),实际 to=%v", plan.headTo) + } + }) + + t.Run("保留期关闭:不做截断", func(t *testing.T) { + plan := buildUsageLogCountPlan(nil, nil, now, 0, dayDate(2026, 6, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 6, 1)) { + t.Fatalf("retention<=0 时下界应仅由 minStat 决定,实际 %v", plan.dailyFrom) + } + }) + + t.Run("范围仅覆盖近两天:昨天走日汇总今天走尾段", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-09-18 00:00:00"), tsPtr(t, "2026-09-19 23:59:59"), now, retention90, dayDate(2026, 6, 1)) + if plan.fallback || !plan.dailyFrom.Equal(dayDate(2026, 9, 18)) || !plan.dailyTo.Equal(dayDate(2026, 9, 18)) { + t.Fatalf("完整天窗口应为 [09-18, 09-18],实际 [%v, %v]", plan.dailyFrom, plan.dailyTo) + } + if plan.hasHead || !plan.hasTail { + t.Fatal("头段应为空、尾段应覆盖今天") + } + }) + + t.Run("范围全在今天:无完整天,回退精确口径", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-09-19 00:00:00"), tsPtr(t, "2026-09-19 23:59:59"), now, retention90, dayDate(2026, 6, 1)) + if !plan.fallback { + t.Fatal("无完整天可走日汇总时应回退") + } + }) + + t.Run("范围全在日汇总起点之前:回退精确口径", func(t *testing.T) { + plan := buildUsageLogCountPlan(tsPtr(t, "2026-05-01 00:00:00"), tsPtr(t, "2026-05-03 23:59:59"), now, retention90, dayDate(2026, 7, 1)) + if !plan.fallback { + t.Fatalf("日汇总未覆盖该范围应回退,实际窗口 [%v, %v]", plan.dailyFrom, plan.dailyTo) + } + }) +} diff --git a/internal/logic/admin/workbench.go b/internal/logic/admin/workbench.go index 6161299..4ff2ec9 100644 --- a/internal/logic/admin/workbench.go +++ b/internal/logic/admin/workbench.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "sort" + "sync" + "sync/atomic" "time" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "golang.org/x/sync/singleflight" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/logic/common" @@ -32,7 +35,14 @@ const ( // workbenchCacheKey 整份收集结果的缓存键。菜单红点轮询与页面刷新共用, // 避免每个管理员每 30s 各打一遍库。 workbenchCacheKey = "workbench:summary:v1" + // workbenchCacheTTL 新鲜期:结果超过这个年龄即视为陈旧,触发后台刷新。 workbenchCacheTTL = 30 * time.Second + // workbenchCacheHardTTL Redis 上的物理 TTL,纯兜底:即使进程在后台刷新途中挂掉, + // 陈旧结果也不会被无限期当成有效数据返回。 + workbenchCacheHardTTL = 10 * time.Minute + // workbenchCollectTimeout 一次完整收集的独立超时。收集发生在请求路径之外 + // (后台刷新),必须有自带的截止时间,不能挂在请求上下文上。 + workbenchCollectTimeout = 30 * time.Second // 各项判定阈值。选值理由见对应 collector 注释。 wbFrozenStaleHours = 2 // 冻结超过 2 小时未释放视为异常 @@ -49,6 +59,15 @@ const ( wbCronStallFallbackInterval = 24 * time.Hour ) +// workbenchGroup 合并并发回源的 singleflight 组。 +// 缓存为空(首次启动 / Redis 丢数据)时,角标轮询与页面刷新会同时到达, +// 没有它就会 N 个请求各跑一遍完整收集,把冷启动放大成 N 倍的库压力。 +var workbenchGroup singleflight.Group + +// workbenchRefreshing 标记「已有后台刷新在跑」。 +// 陈旧命中时每个请求都想刷新,用 CAS 保证全局同时只有一次刷新。 +var workbenchRefreshing atomic.Bool + // wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。 // 工作台跨域聚合,必须按权限逐条过滤 —— 只有 support:view 的客服不该在工作台 // 看到钱包偏差和订单金额,否则等于绕过 RBAC 泄露财务数据。 @@ -64,6 +83,10 @@ type wbCollected struct { Breakers []v1.WorkbenchBreaker `json:"breakers"` Metrics []v1.WorkbenchMetric `json:"metrics"` GeneratedAt string `json:"generated_at"` + // GeneratedAtUnix 缓存新鲜度的判定依据(Unix 秒)。 + // 刻意不复用 GeneratedAt:那个字符串由 gtime 格式化后对外展示,格式细节 + // 不该被缓存逻辑依赖;这里用明确的时间戳,解析歧义为零。 + GeneratedAtUnix int64 `json:"generated_at_unix"` } // ============================================================ @@ -144,21 +167,105 @@ func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBad // 收集与过滤 // ============================================================ -// collectWorkbenchCached 读缓存,未命中则重新收集。 -// 缓存失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。 +// collectWorkbenchCached 读缓存;命中即返回,陈旧则「先返回旧值 + 后台刷新」。 +// +// 三层保护,针对菜单角标每 30s 轮询这一固定负载: +// 1. 新鲜命中:直接返回,零 DB 开销; +// 2. 陈旧命中:立即返回旧值,同时触发一次后台刷新(stale-while-revalidate)—— +// 工作台是故障时最该能打开的页面,宁可给一份 30 秒前的数据,也不要让轮询 +// 把请求堵在十几条联表查询上; +// 3. 完全未命中(首次启动 / Redis 丢数据):同步收集,由 singleflight 保证 +// 并发请求只触发一次。 +// +// 缓存读写失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。 func (s *sAdmin) collectWorkbenchCached(ctx context.Context) *wbCollected { - if v, err := g.Redis().Get(ctx, workbenchCacheKey); err == nil && !v.IsEmpty() { - var col wbCollected - if json.Unmarshal(v.Bytes(), &col) == nil { - return &col + if col, ok := readWorkbenchCache(ctx); ok { + if wbCacheFresh(col) { + return col } + s.refreshWorkbenchAsync(ctx) + return col } + return s.collectWorkbenchSync(ctx) +} - col := s.collectWorkbench(ctx) +// readWorkbenchCache 读并解析整份收集结果。任何一步失败都当作未命中。 +func readWorkbenchCache(ctx context.Context) (*wbCollected, bool) { + v, err := g.Redis().Get(ctx, workbenchCacheKey) + if err != nil || v.IsEmpty() { + return nil, false + } + var col wbCollected + if json.Unmarshal(v.Bytes(), &col) != nil { + return nil, false + } + return &col, true +} + +// wbCacheFresh 判断缓存是否还在新鲜期内。 +// GeneratedAtUnix 缺失(例如版本升级后读到旧格式缓存)时判为陈旧:那会触发 +// 恰好一次刷新把字段补上,之后自愈,不会形成刷新循环。 +func wbCacheFresh(col *wbCollected) bool { + if col.GeneratedAtUnix <= 0 { + return false + } + return time.Since(time.Unix(col.GeneratedAtUnix, 0)) < workbenchCacheTTL +} + +// collectWorkbenchSync 冷启动路径:同步收集,singleflight 保证并发只跑一次。 +func (s *sAdmin) collectWorkbenchSync(ctx context.Context) *wbCollected { + v, _, _ := workbenchGroup.Do(workbenchCacheKey, func() (any, error) { + // 双检:等锁期间别的请求可能已经把缓存写好了 + if col, ok := readWorkbenchCache(ctx); ok { + return col, nil + } + return s.collectWorkbenchAndCache(ctx), nil + }) + if col, ok := v.(*wbCollected); ok { + return col + } + // 理论不可达(Do 恒返回 *wbCollected);兜底直接收集,保证工作台永远有数据 + return s.collectWorkbenchAndCache(ctx) +} + +// refreshWorkbenchAsync 后台刷新一次缓存。 +// 全局只允许一个刷新在跑:陈旧期间所有轮询请求都会走到这里,不去重等于把 +// 「每个请求各刷一次」原样搬到后台。 +func (s *sAdmin) refreshWorkbenchAsync(ctx context.Context) { + if !workbenchRefreshing.CompareAndSwap(false, true) { + return + } + // 脱离请求生命周期:请求返回后刷新必须继续,否则角标轮询一结束刷新就被取消, + // 缓存永远等不到更新。值(trace id 等)保留,取消信号丢弃。 + bg := context.WithoutCancel(ctx) + go func() { + defer workbenchRefreshing.Store(false) + // 后台刷新跑在裸 goroutine 上,没有 HTTP 服务的 panic 恢复兜底 —— + // 任一 collector 空指针都不能把整个进程带走,落一条日志就够了。 + // defer 顺序:本函数先注册,后执行,确保 recover 之后标志位一定被清掉。 + defer func() { + if e := recover(); e != nil { + g.Log().Errorf(bg, "workbench: 后台刷新 panic: %v", e) + } + }() + s.collectWorkbenchAndCache(bg) + }() +} + +// collectWorkbenchAndCache 收集一次并写回缓存。 +// +// 收集统一跑在「脱离请求生命周期 + 自带超时」的上下文上:结果会写进全实例共享的 +// 缓存,不能被某个客户端断连中断 —— 否则一份半截结果(最坏是「全站无待办」) +// 会被当成有效数据缓存下来。 +func (s *sAdmin) collectWorkbenchAndCache(ctx context.Context) *wbCollected { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workbenchCollectTimeout) + defer cancel() + + col := s.collectWorkbench(rctx) if b, err := json.Marshal(col); err == nil { - if _, err := g.Redis().Do(ctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheTTL.Seconds())); err != nil { - g.Log().Warningf(ctx, "workbench: 写缓存失败: %v", err) + if _, err := g.Redis().Do(rctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheHardTTL.Seconds())); err != nil { + g.Log().Warningf(rctx, "workbench: 写缓存失败: %v", err) } } return col @@ -217,23 +324,70 @@ func (s *sAdmin) filterWorkbenchItems(ctx context.Context, items []wbItem) []wbI return out } -// collectWorkbench 依次跑各 collector。单个失败不影响整体。 +// collectWorkbench 并发跑各 collector。单个失败不影响整体。 +// +// 四个域 + 首屏指标之间没有任何数据依赖,串行执行只会把一次工作台刷新累加成 +// 「十几条联表查询耗时之和」。并发后总耗时≈最慢的那一条(通常是资金域 —— +// 它要对最多 30 个租户逐个读 Redis)。 +// +// wg.Wait() 返回后才合并结果,各 goroutine 之间没有共享写入;合并顺序固定为 +// 可用性 → 资金 → 客户 → 系统,保证同样的数据产出稳定的 items 顺序。 +// +// 用 WaitGroup 而非 errgroup:collector 全部自带兜底(失败只记 warning 并返回空), +// 这里不存在可返回的错误。 func (s *sAdmin) collectWorkbench(ctx context.Context) *wbCollected { - col := &wbCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)} + now := gtime.Now() + col := &wbCollected{ + GeneratedAt: now.Format(time.RFC3339), + GeneratedAtUnix: now.Unix(), + } + + var ( + models []v1.WorkbenchModelAvail + breakers []v1.WorkbenchBreaker + availItems []wbItem + moneyItems []wbItem + customerItems []wbItem + systemItems []wbItem + metrics []v1.WorkbenchMetric + ) + + var wg sync.WaitGroup + wg.Add(5) + go wbSafe(ctx, &wg, "availability", func() { models, breakers, availItems = s.wbCollectAvailability(ctx) }) + go wbSafe(ctx, &wg, "money", func() { moneyItems = s.wbCollectMoney(ctx) }) + go wbSafe(ctx, &wg, "customer", func() { customerItems = s.wbCollectCustomer(ctx) }) + go wbSafe(ctx, &wg, "system", func() { systemItems = s.wbCollectSystem(ctx) }) + go wbSafe(ctx, &wg, "metrics", func() { metrics = s.wbCollectMetrics(ctx) }) + wg.Wait() - models, breakers, availItems := s.wbCollectAvailability(ctx) col.Models = models col.Breakers = breakers - col.Items = append(col.Items, availItems...) - col.Items = append(col.Items, s.wbCollectMoney(ctx)...) - col.Items = append(col.Items, s.wbCollectCustomer(ctx)...) - col.Items = append(col.Items, s.wbCollectSystem(ctx)...) - - col.Metrics = s.wbCollectMetrics(ctx) + col.Items = append(col.Items, moneyItems...) + col.Items = append(col.Items, customerItems...) + col.Items = append(col.Items, systemItems...) + col.Metrics = metrics return col } +// wbSafe 跑单个 collector,负责 wg.Done 并吞掉 panic。 +// collector 自身已经兜住了「查询报错」(记 warning 返回空),这里补的是 +// 「不可恢复错误」:并发化之后 panic 发生在子 goroutine,HTTP 中间件的 +// 恢复逻辑接不住它 —— 不 recover 就是一次空指针带走整个服务。 +// +// defer 顺序:wg.Done 先注册后执行,保证 recover 之后计数一定被减掉 +// (否则一次 panic 就会让 collectWorkbench 永久卡在 wg.Wait)。 +func wbSafe(ctx context.Context, wg *sync.WaitGroup, name string, fn func()) { + defer wg.Done() + defer func() { + if e := recover(); e != nil { + g.Log().Errorf(ctx, "workbench: collector %s panic: %v", name, e) + } + }() + fn() +} + // wbNew 构造一条待办。 func wbNew(key, sev, domain, perm, title, desc, actionText, route string, query map[string]string, at *gtime.Time) wbItem { it := wbItem{Perm: perm} diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go index f75ef5e..cdd695a 100644 --- a/internal/logic/billing/currency.go +++ b/internal/logic/billing/currency.go @@ -33,6 +33,16 @@ func IsCNY(ctx context.Context) bool { return Currency(ctx) == consts.BillingCurrencyCNY } +// CurrencySymbol 返回本位币的货币符号,用于服务端生成的展示文本 +// (计费摘要、CSV/Excel 导出等前端拿不到结构化金额、无法走 useCurrency 的场景)。 +// 仅适用于 bil_ 记账层金额;ord_/pln_ 层金额恒为 CNY,直接写 ¥,不要用本函数。 +func CurrencySymbol(ctx context.Context) string { + if Currency(ctx) == consts.BillingCurrencyCNY { + return "¥" + } + return "$" +} + // ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。 // 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。 func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal { diff --git a/internal/logic/billing/e2e_param_multiplier_test.go b/internal/logic/billing/e2e_param_multiplier_test.go new file mode 100644 index 0000000..e7b512d --- /dev/null +++ b/internal/logic/billing/e2e_param_multiplier_test.go @@ -0,0 +1,121 @@ +//go:build e2e + +// 手动 E2E(需真实 DB/Redis;默认 go test 不编译本文件)。 +// 跑法:go test -tags e2e -run TestE2EParamMultiplier ./internal/logic/billing/ +// 使用独立的临时模型行 + 不存在的租户 ID,走纯基础定价路径,结束即清理,不影响现有数据。 +// 验证链路:pricing JSONB 配置 param_multipliers → TaskBillingProvider.EstimateTaskCost +// 携归一化任务体求值 → ratios 注入 param_multiplier/param_matched → 费用连乘。 + +package billing + +import ( + "fmt" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" + "github.com/gogf/gf/v2/os/gcfg" + "github.com/gogf/gf/v2/os/gctx" + + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" +) + +func TestE2EParamMultiplier(t *testing.T) { + // 测试 CWD 是包目录,把配置搜索路径指回仓库根的 manifest/config + adapterFile, err := gcfg.NewAdapterFile() + if err != nil { + t.Fatalf("new config adapter: %v", err) + } + if err := adapterFile.AddPath("../../../manifest/config"); err != nil { + t.Fatalf("add config path: %v", err) + } + gcfg.Instance().SetAdapter(adapterFile) + ctx := gctx.New() + + const tmpModel = "tmp-e2e-param-multiplier" + const tmpTenant = int64(999999998) // 不存在的租户:走纯基础定价路径 + + cleanup := func() { + var id int64 + v, _ := dao.MdlModels.Ctx(ctx).Where("model_id", tmpModel).Fields("id").Value() + fmt.Sscanf(v.String(), "%d", &id) + if id > 0 { + _, _ = dao.MdlPricing.Ctx(ctx).Where("model_id", id).Delete() + _, _ = dao.MdlModels.Ctx(ctx).Where("id", id).Delete() + } + ClearModelPriceCache(ctx, tmpModel) + } + cleanup() + defer cleanup() + + // 临时模型 + per_second 定价(含参数倍率规则:图生视频 ×1.5,≥10 秒 ×2.0,视频输入 ×0.609) + modelID, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(do.MdlModels{ + ModelId: tmpModel, + ModelName: "E2E参数倍率临时模型", + Category: "video", + Status: "active", + }) + if err != nil { + t.Fatalf("insert tmp model: %v", err) + } + pricingJSON := `{ + "unit": "second", + "prices": {"720p": 0.5, "1080p": 1.0, "*": 0.5}, + "param_multipliers": [ + {"conditions": [{"path": "metadata.image", "match": "has_value"}], "multiplier": 1.5, "note": "图生视频"}, + {"conditions": [{"path": "seconds", "match": "gte", "value": 10}], "multiplier": 2.0, "note": "长视频"}, + {"conditions": [{"path": "metadata.content[*].video_url", "match": "has_value"}], "multiplier": 0.609, "note": "视频输入折扣"} + ] + }` + if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ + ModelId: modelID, + BillingMode: "per_second", + Pricing: pricingJSON, + }); err != nil { + t.Fatalf("insert tmp pricing: %v", err) + } + + provider := NewTaskBillingProvider() + + // 1. 命中「图生视频 ×1.5」+「长视频 ×2.0」(seconds 字符串 "12" 数字强转参与比较): + // 费用 = 0.5(720p) × 12s × 1.5 × 2.0 = 18.0;ratios 注入 param_multiplier=3 / param_matched + body := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`) + ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"} + cost, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios, body) + if err != nil { + t.Fatalf("estimate: %v", err) + } + assertDecimal(t, cost, 18.0, "e2e param multiplier cost (0.5×12×1.5×2)") + if m, _ := ratios["param_multiplier"].(float64); m != 3.0 { + t.Fatalf("param_multiplier injected = %v (type %T), want 3.0", ratios["param_multiplier"], ratios["param_multiplier"]) + } + if matched, _ := ratios["param_matched"].(string); matched != "图生视频|长视频" { + t.Fatalf("param_matched = %q, want 图生视频|长视频", matched) + } + + // 2. 纯文生视频(无图、5 秒):不命中任何规则,无注入;费用 = 0.5 × 5 = 2.5 + ratios2 := map[string]any{"spec.duration": 5.0} + body2 := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"5"}`) + cost2, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios2, body2) + if err != nil { + t.Fatalf("estimate2: %v", err) + } + assertDecimal(t, cost2, 2.5, "e2e no-match cost") + if _, injected := ratios2["param_multiplier"]; injected { + t.Fatalf("no-match should not inject, got %v", ratios2["param_multiplier"]) + } + + // 3. 视频输入折扣([*] 路径)+ 1080p:费用 = 1.0 × 8 × 0.609 = 4.872 + ratios3 := map[string]any{"spec.duration": 8.0, "spec.resolution": "1080p"} + body3 := []byte(`{"model":"` + tmpModel + `","prompt":"p","metadata":{"resolution":"1080p","duration":8,"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`) + cost3, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios3, body3) + if err != nil { + t.Fatalf("estimate3: %v", err) + } + assertDecimal(t, cost3, 4.872, "e2e video-input discount cost (1.0×8×0.609)") + + // 4. 定价缓存命中路径同样生效(第二次调用走 600s 缓存里的 ParamMultipliers) + ratios4 := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"} + cost4, _ := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios4, body) + assertDecimal(t, cost4, 18.0, "e2e cached param multiplier cost") +} diff --git a/internal/logic/billing/e2e_time_segment_test.go b/internal/logic/billing/e2e_time_segment_test.go index a0b3138..7f606b3 100644 --- a/internal/logic/billing/e2e_time_segment_test.go +++ b/internal/logic/billing/e2e_time_segment_test.go @@ -62,13 +62,12 @@ func TestE2ETimeSegmentPricing(t *testing.T) { start := now.Add(-time.Hour).Format("15:04") end := now.Add(time.Hour).Format("15:04") segJSON := fmt.Sprintf(`[{"name":"闲时","start_time":%q,"end_time":%q,"multiplier":0.5}]`, start, end) + // pricing JSONB 单模式存储:token 价 + 时段配置(时段并入顶层) + pricingJSON := fmt.Sprintf(`{"input_price":3,"output_price":15,"time_segments":%s}`, segJSON) if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ - ModelId: modelID, - BillingMode: "token", - MinTokens: 0, - InputPrice: NewFromFloat(3), - OutputPrice: NewFromFloat(15), - TimeSegments: segJSON, + ModelId: modelID, + BillingMode: "token", + Pricing: pricingJSON, }); err != nil { t.Fatalf("insert tmp pricing: %v", err) } diff --git a/internal/logic/billing/money.go b/internal/logic/billing/money.go index fe8fda0..c845bde 100644 --- a/internal/logic/billing/money.go +++ b/internal/logic/billing/money.go @@ -27,10 +27,10 @@ const ( // 预定义常量(减少重复 NewFromInt 调用) var ( - Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算 - MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容) - Zero = decimal.Zero // 常用零值 - One = decimal.NewFromInt(1) // 常用 1 + Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算 + MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容) + Zero = decimal.Zero // 常用零值 + One = decimal.NewFromInt(1) // 常用 1 ) // RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)。 diff --git a/internal/logic/billing/param_multiplier.go b/internal/logic/billing/param_multiplier.go new file mode 100644 index 0000000..b60d249 --- /dev/null +++ b/internal/logic/billing/param_multiplier.go @@ -0,0 +1,292 @@ +package billing + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// 参数倍率(param_multipliers):按「网关归一化后的任务体」中的请求参数匹配规则, +// 对命中规则连乘倍率。规则配置在 mdl_pricing.pricing JSONB 顶层(横切所有计费模式,与 time_segments 同级)。 +// +// 设计约束(docs/模型定价存储JSON化与按秒计费设计.md 3.2): +// - 匹配源 = 转换后的通用任务体({model, prompt, seconds, metadata{...}}),规则跟语义走不跟入口协议走; +// - 一条规则内扁平条件列表 = 隐式 AND(全部满足才命中),不做嵌套布尔组(已否决); +// - 规则间连乘,互斥分支多配几条规则即 OR; +// - 不做 NOT / 正则 / 范围外运算符; +// - 数字强转:字符串可转数字则参与比较("/v1/videos 的 seconds 是字符串")。 + +// ParamCondition 单个匹配条件 +type ParamCondition struct { + Path string `json:"path"` // 任务体 JSON 路径,支持 [*] 数组通配(任一元素命中即命中),如 metadata.content[*].video_url + Match string `json:"match"` // has_value | equals | gt | gte | lt | lte | between + Value any `json:"value,omitempty"` // equals/gt/gte/lt/lte 单值;between 为 [min,max] 双元素数组 +} + +// ParamRule 参数倍率规则:conditions 全部满足时按 multiplier 计入连乘 +type ParamRule struct { + Conditions []ParamCondition `json:"conditions"` + Multiplier float64 `json:"multiplier"` + Note string `json:"note,omitempty"` // 命中说明,写入计费上下文供对账解释 +} + +// 求值注入的 ratios 键(随任务 PrivateData.billing_context 持久化, +// 预扣 / per_second 结算 / token 重算三口径经 applyRatioMultipliers 自动同源) +const ( + ratioKeyParamMultiplier = "param_multiplier" + ratioKeyParamMatched = "param_matched" // 命中规则 note 列表(| 分隔);string 值不参与乘法 +) + +// validParamMatches 合法匹配运算符集合 +var validParamMatches = map[string]bool{ + "has_value": true, "equals": true, + "gt": true, "gte": true, "lt": true, "lte": true, "between": true, +} + +// ValidateParamMultipliers 校验规则配置(SetModelPricing / 模型导入 fail-fast 用) +func ValidateParamMultipliers(rules []ParamRule) error { + for i, rule := range rules { + if len(rule.Conditions) == 0 { + return fmt.Errorf("参数倍率规则 %d 至少需要一个条件", i+1) + } + if rule.Multiplier <= 0 || rule.Multiplier > 100 { + return fmt.Errorf("参数倍率规则 %d 倍率需在 (0, 100] 区间", i+1) + } + for j, cond := range rule.Conditions { + if cond.Path == "" { + return fmt.Errorf("参数倍率规则 %d 条件 %d 路径为空", i+1, j+1) + } + if !validParamMatches[cond.Match] { + return fmt.Errorf("参数倍率规则 %d 条件 %d 匹配方式非法:%s", i+1, j+1, cond.Match) + } + switch cond.Match { + case "between": + arr, ok := cond.Value.([]any) + if !ok || len(arr) != 2 { + return fmt.Errorf("参数倍率规则 %d 条件 %d between 需要 [min,max] 双元素", i+1, j+1) + } + if _, ok1 := toNumber(arr[0]); !ok1 { + return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1) + } + if _, ok2 := toNumber(arr[1]); !ok2 { + return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1) + } + case "has_value": + // 无需值 + default: // equals / gt / gte / lt / lte 需要比对值 + if cond.Value == nil { + return fmt.Errorf("参数倍率规则 %d 条件 %d 需要指定比对值", i+1, j+1) + } + if cond.Match != "equals" { + if _, ok := toNumber(cond.Value); !ok { + return fmt.Errorf("参数倍率规则 %d 条件 %d %s 需要数字值", i+1, j+1, cond.Match) + } + } + } + } + } + return nil +} + +// EvalParamMultipliers 对任务体求值参数倍率:命中规则连乘,返回 (总倍率, 命中说明列表)。 +// 无规则/无命中返回 (1.0, nil);解析失败的任务体安全返回未命中(不阻断计费)。 +func EvalParamMultipliers(rules []ParamRule, taskBody []byte) (float64, []string) { + if len(rules) == 0 || len(taskBody) == 0 { + return 1.0, nil + } + var root any + if err := json.Unmarshal(taskBody, &root); err != nil { + return 1.0, nil + } + + total := 1.0 + var matched []string + for _, rule := range rules { + if rule.Multiplier <= 0 || !ruleConditionsMatch(rule.Conditions, root) { + continue + } + total *= rule.Multiplier + if rule.Note != "" { + matched = append(matched, rule.Note) + } else { + matched = append(matched, strings.Join(conditionPaths(rule.Conditions), " & ")) + } + } + if len(matched) == 0 { + return 1.0, nil + } + return total, matched +} + +// ruleConditionsMatch 隐式 AND:全部条件满足才命中 +func ruleConditionsMatch(conds []ParamCondition, root any) bool { + for _, cond := range conds { + if !conditionMatch(cond, root) { + return false + } + } + return true +} + +// conditionMatch 单条件匹配:路径提取([*] 扇出)后按运算符判断,任一候选值满足即命中 +func conditionMatch(cond ParamCondition, root any) bool { + values, found := lookupPath(root, cond.Path) + if !found { + return false + } + switch cond.Match { + case "has_value": + // 路径存在且任一候选值非 null(false/0/"" 均视为"有值"——与"未传"语义不同) + for _, v := range values { + if v != nil { + return true + } + } + return false + case "equals": + for _, v := range values { + if valuesEqual(v, cond.Value) { + return true + } + } + return false + case "between": + arr, ok := cond.Value.([]any) + if !ok || len(arr) != 2 { + return false + } + lo, ok1 := toNumber(arr[0]) + hi, ok2 := toNumber(arr[1]) + if !ok1 || !ok2 { + return false + } + for _, v := range values { + if n, ok := toNumber(v); ok && n >= lo && n <= hi { + return true + } + } + return false + default: // gt / gte / lt / lte + target, ok := toNumber(cond.Value) + if !ok { + return false + } + for _, v := range values { + n, ok := toNumber(v) + if !ok { + continue + } + switch cond.Match { + case "gt": + if n > target { + return true + } + case "gte": + if n >= target { + return true + } + case "lt": + if n < target { + return true + } + case "lte": + if n <= target { + return true + } + } + } + return false + } +} + +// lookupPath 按点分路径提取候选值([*] 数组通配扇出)。 +// found=false 表示路径不存在(键缺失或类型不符);[*] 命中空数组时 found=false。 +func lookupPath(root any, path string) (values []any, found bool) { + segments := strings.Split(path, ".") + current := []any{root} + for _, seg := range segments { + if seg == "" { + return nil, false + } + wildcard := strings.HasSuffix(seg, "[*]") + key := seg + if wildcard { + key = strings.TrimSuffix(seg, "[*]") + } + var next []any + for _, cur := range current { + m, ok := cur.(map[string]any) + if !ok { + continue + } + v, exists := m[key] + if !exists { + continue + } + if wildcard { + arr, ok := v.([]any) + if !ok { + continue + } + next = append(next, arr...) + } else { + next = append(next, v) + } + } + if len(next) == 0 { + return nil, false + } + current = next + } + return current, true +} + +// valuesEqual 等值比较:数字强转后比较("8" == 8),string/bool 严格相等 +func valuesEqual(v, target any) bool { + if target == nil { + return false + } + // 数字优先:两侧都能转数字则按数值比 + if vn, ok1 := toNumber(v); ok1 { + if tn, ok2 := toNumber(target); ok2 { + return vn == tn + } + } + vs, ok1 := v.(string) + ts, ok2 := target.(string) + if ok1 && ok2 { + return vs == ts + } + vb, ok1 := v.(bool) + tb, ok2 := target.(bool) + if ok1 && ok2 { + return vb == tb + } + return false +} + +// toNumber 数字强转:JSON number(float64)/ 可解析数字字符串 → float64;bool/对象/数组不可转 +func toNumber(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(n), 64) + if err == nil { + return f, true + } + return 0, false + default: + return 0, false + } +} + +// conditionPaths 条件路径列表(规则无 note 时作命中说明兜底) +func conditionPaths(conds []ParamCondition) []string { + paths := make([]string, 0, len(conds)) + for _, c := range conds { + paths = append(paths, c.Path) + } + return paths +} diff --git a/internal/logic/billing/param_multiplier_test.go b/internal/logic/billing/param_multiplier_test.go new file mode 100644 index 0000000..6bacf06 --- /dev/null +++ b/internal/logic/billing/param_multiplier_test.go @@ -0,0 +1,304 @@ +package billing + +import ( + "encoding/json" + "testing" +) + +// ── lookupPath 路径提取 ── + +func TestLookupPath(t *testing.T) { + var root any + json.Unmarshal([]byte(`{ + "model": "m", + "seconds": "8", + "metadata": { + "image": "https://x", + "duration": 8, + "resolution": null, + "content": [ + {"type": "text", "text": "hi"}, + {"type": "image_url", "image_url": {"url": "a"}, "role": "first_frame"}, + {"type": "video_url", "video_url": {"url": "v"}} + ] + } + }`), &root) + + cases := []struct { + path string + found bool + count int + }{ + {"model", true, 1}, + {"seconds", true, 1}, + {"metadata.image", true, 1}, + {"metadata.duration", true, 1}, + {"metadata.resolution", true, 1}, // 存在但值为 null + {"metadata.missing", false, 0}, + {"metadata", true, 1}, + {"metadata.content", true, 1}, + {"metadata.content[*].type", true, 3}, + {"metadata.content[*].video_url.url", true, 1}, + {"metadata.content[*].role", true, 1}, + {"metadata.content[*].missing", false, 0}, + {"", false, 0}, + {"metadata..image", false, 0}, + } + for _, c := range cases { + values, found := lookupPath(root, c.path) + if found != c.found { + t.Errorf("lookupPath(%q) found=%v want %v", c.path, found, c.found) + continue + } + if found && len(values) != c.count { + t.Errorf("lookupPath(%q) values=%d want %d", c.path, len(values), c.count) + } + } +} + +// ── 匹配语义 ── + +func evalOne(t *testing.T, body string, conds ...ParamCondition) bool { + t.Helper() + return ruleConditionsMatch(conds, jsonMustParse(t, body)) +} + +func jsonMustParse(t *testing.T, s string) any { + t.Helper() + var root any + if err := json.Unmarshal([]byte(s), &root); err != nil { + t.Fatalf("parse: %v", err) + } + return root +} + +func TestMatchHasValue(t *testing.T) { + // false/0/空串均为"有值"(与未传语义不同);null 不算 + if !evalOne(t, `{"watermark": false}`, ParamCondition{Path: "watermark", Match: "has_value"}) { + t.Error("false should count as has_value") + } + if !evalOne(t, `{"x": ""}`, ParamCondition{Path: "x", Match: "has_value"}) { + t.Error("empty string should count as has_value") + } + if !evalOne(t, `{"x": 0}`, ParamCondition{Path: "x", Match: "has_value"}) { + t.Error("zero should count as has_value") + } + if evalOne(t, `{"x": null}`, ParamCondition{Path: "x", Match: "has_value"}) { + t.Error("null should NOT count as has_value") + } + if evalOne(t, `{}`, ParamCondition{Path: "x", Match: "has_value"}) { + t.Error("missing key should not match") + } + // 数组:任一元素非 null + if !evalOne(t, `{"content":[{"video_url":null},{"video_url":{"url":"v"}}]}`, + ParamCondition{Path: "content[*].video_url", Match: "has_value"}) { + t.Error("any non-null element should match") + } +} + +func TestMatchEquals(t *testing.T) { + // 数字强转:"8" == 8 + if !evalOne(t, `{"seconds": "8"}`, ParamCondition{Path: "seconds", Match: "equals", Value: float64(8)}) { + t.Error(`"8" should equal 8`) + } + if !evalOne(t, `{"mode": "pro"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) { + t.Error("string equals") + } + if !evalOne(t, `{"flag": true}`, ParamCondition{Path: "flag", Match: "equals", Value: true}) { + t.Error("bool equals") + } + if evalOne(t, `{"mode": "std"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) { + t.Error("different string should not equal") + } +} + +func TestMatchComparisons(t *testing.T) { + cond := func(match string, val float64) ParamCondition { + return ParamCondition{Path: "metadata.duration", Match: match, Value: val} + } + body := `{"metadata":{"duration":8}}` + for _, m := range []string{"gt", "gte", "lt", "lte"} { + var want bool + switch m { + case "gt": + want = false + case "gte": + want = true + case "lt": + want = false + case "lte": + want = true + } + if got := evalOne(t, body, cond(m, 8)); got != want { + t.Errorf("%s 8 vs 8 = %v want %v", m, got, want) + } + } + // 字符串数字强转参与比较 + if !evalOne(t, `{"seconds":"12"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) { + t.Error(`"12" gte 10 should match`) + } + // 非数字字符串不参与比较 + if evalOne(t, `{"seconds":"abc"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) { + t.Error("non-numeric string should not match comparison") + } +} + +func TestMatchBetween(t *testing.T) { + cond := ParamCondition{Path: "metadata.duration", Match: "between", Value: []any{float64(8), float64(12)}} + if !evalOne(t, `{"metadata":{"duration":8}}`, cond) { + t.Error("between should be inclusive of min") + } + if !evalOne(t, `{"metadata":{"duration":12}}`, cond) { + t.Error("between should be inclusive of max") + } + if evalOne(t, `{"metadata":{"duration":7}}`, cond) { + t.Error("below min should not match") + } + // 数组任一元素落入区间 + if !evalOne(t, `{"arr":[{"d":3},{"d":9}]}`, ParamCondition{Path: "arr[*].d", Match: "between", Value: []any{float64(8), float64(12)}}) { + t.Error("any element in range should match") + } +} + +// ── 规则求值 ── + +func TestEvalParamMultipliers(t *testing.T) { + rules := []ParamRule{ + { + // 图生视频:metadata.image 有值 → 1.5 + Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}}, + Multiplier: 1.5, + Note: "图生视频", + }, + { + // 隐式 AND:1080p 且 ≥10 秒 → 2.0 + Conditions: []ParamCondition{ + {Path: "metadata.resolution", Match: "equals", Value: "1080p"}, + {Path: "metadata.duration", Match: "gte", Value: float64(10)}, + }, + Multiplier: 2.0, + Note: "1080p 长视频", + }, + { + // 视频输入折扣 + Conditions: []ParamCondition{{Path: "metadata.content[*].video_url", Match: "has_value"}}, + Multiplier: 0.609, + Note: "视频输入折扣", + }, + } + + // 命中规则 1(×1.5),规则 2 分辨率不满足 + m, matched := EvalParamMultipliers(rules, []byte(`{"model":"m","metadata":{"image":"https://x","resolution":"720p","duration":5}}`)) + if m != 1.5 || len(matched) != 1 || matched[0] != "图生视频" { + t.Fatalf("case1: m=%v matched=%v", m, matched) + } + + // 命中规则 1 + 规则 2(连乘 3.0) + m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":12}}`)) + if m != 3.0 || len(matched) != 2 { + t.Fatalf("case2: m=%v matched=%v", m, matched) + } + + // 全不命中 → 1.0 / nil + m, matched = EvalParamMultipliers(rules, []byte(`{"model":"m"}`)) + if m != 1.0 || matched != nil { + t.Fatalf("case3: m=%v matched=%v", m, matched) + } + + // AND 语义:一个条件不满足整条不命中 + m, _ = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":5}}`)) + if m != 1.5 { + t.Fatalf("case4: partial-AND should only match rule1, m=%v", m) + } + + // 视频输入折扣([*] 路径) + m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`)) + if m != 0.609 || len(matched) != 1 { + t.Fatalf("case5: m=%v matched=%v", m, matched) + } + + // 无规则 / 空 body / 非法 JSON → 1.0 + if m, _ := EvalParamMultipliers(nil, []byte(`{}`)); m != 1.0 { + t.Error("no rules should be 1.0") + } + if m, _ := EvalParamMultipliers(rules, nil); m != 1.0 { + t.Error("nil body should be 1.0") + } + if m, _ := EvalParamMultipliers(rules, []byte(`not-json`)); m != 1.0 { + t.Error("invalid json should be 1.0") + } +} + +func TestEvalParamMultipliers_MultiplierProductOrder(t *testing.T) { + // 无 note 时命中说明回退为路径拼接 + rules := []ParamRule{ + {Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 2.0}, + {Conditions: []ParamCondition{{Path: "b", Match: "has_value"}}, Multiplier: 3.0}, + } + m, matched := EvalParamMultipliers(rules, []byte(`{"a":1,"b":1}`)) + if m != 6.0 || len(matched) != 2 || matched[0] != "a" { + t.Fatalf("m=%v matched=%v", m, matched) + } +} + +// ── 校验 ── + +func TestValidateParamMultipliers(t *testing.T) { + ok := []ParamRule{{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: float64(1)}}, Multiplier: 1.5}} + if err := ValidateParamMultipliers(ok); err != nil { + t.Fatalf("valid rules rejected: %v", err) + } + + bad := []struct { + name string + rule ParamRule + }{ + {"空条件", ParamRule{Conditions: nil, Multiplier: 1.5}}, + {"倍率为零", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 0}}, + {"倍率超上限", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 101}}, + {"路径为空", ParamRule{Conditions: []ParamCondition{{Path: "", Match: "has_value"}}, Multiplier: 1.5}}, + {"匹配方式非法", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "regex"}}, Multiplier: 1.5}}, + {"gte 缺值", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte"}}, Multiplier: 1.5}}, + {"gte 非数字", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: "abc"}}, Multiplier: 1.5}}, + {"between 非数组", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: float64(1)}}, Multiplier: 1.5}}, + {"between 单元素", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: []any{float64(1)}}}, Multiplier: 1.5}}, + } + for _, c := range bad { + if err := ValidateParamMultipliers([]ParamRule{c.rule}); err == nil { + t.Errorf("%s: should be rejected", c.name) + } + } +} + +// ── EstimateTaskCost 注入集成 ── + +func TestEstimateTaskCost_ParamMultiplierInjection(t *testing.T) { + // per_second 模型 + 参数倍率规则:命中注入 ratios 且费用连乘 + pricing := &PricingResult{ + BillingMode: "per_second", + TenantMultiplier: 1.0, + PerSecondPrices: map[string]float64{"720p": 0.5, "*": 0.5}, + ParamMultipliers: []ParamRule{ + {Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}}, Multiplier: 1.5, Note: "图生视频"}, + {Conditions: []ParamCondition{{Path: "seconds", Match: "gte", Value: float64(10)}}, Multiplier: 2.0, Note: "长视频"}, + }, + } + // 直接调纯函数路径(estimateTaskCost 只吃 ratios)——先验证求值注入逻辑的等价小函数 + ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"} + body := []byte(`{"model":"m","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`) + + if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); m != 3.0 || len(matched) != 2 { + t.Fatalf("eval: m=%v matched=%v", m, matched) + } + // 模拟 EstimateTaskCost 内部注入(保持与实现一致的键名) + if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); len(matched) > 0 && m > 0 { + ratios[ratioKeyParamMultiplier] = m + ratios[ratioKeyParamMatched] = matched[0] + } + // 注入后费用 = 0.5 × 12 × 1.5 × 2.0 = 18 + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 18.0, "param multiplier cost") + + // 未命中的任务体不注入(保持 0.5 × 5 缺省 = 2.5) + ratios2 := map[string]any{} + assertDecimal(t, estimateTaskCost(pricing, ratios2, nil), 2.5, "no param match cost") +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 5130b0e..c780790 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math" + "strings" "time" "github.com/gogf/gf/v2/errors/gerror" @@ -57,15 +58,80 @@ type PricingResult struct { MaxOutputTokens int // 租户自定义阶梯定价(JSONB 解析后的原始数据,供 CalculateCost 使用) - // 租户未自定义时回落为平台阶梯(mdl_pricing 的 min_tokens 档位行) + // 租户未自定义时回落为平台阶梯(pricing JSONB 的 tiers 数组) CustomTiers []pricingTierRow `json:"CustomTiers"` - // 时段定价配置(mdl_pricing 锚点行 time_segments,随定价缓存存储) + // 时段定价配置(pricing JSONB 顶层 time_segments,随定价缓存存储) TimeSegments []TimeSegment `json:"time_segments,omitempty"` // 时段乘数(读取时按定价时刻评估;缓存条目中的值是写入时刻的结果,仅作参考) TimeMultiplier float64 `json:"time_multiplier"` // 命中的时段名(写入计费快照,供账单解释;未命中为空) TimeRuleName string `json:"time_rule_name"` + + // 按秒计费矩阵(billing_mode=per_second,mdl_pricing.pricing JSONB 的 prices 键): + // 分辨率/规格 → 每秒单价(本位币)。"*" 为兜底价,规格未命中时使用。 + // 旧缓存条目无此字段(zero value = 空 map),token/per_request 模式不受影响。 + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` + + // 参数倍率规则(pricing JSONB 顶层 param_multipliers,横切所有模式): + // 按归一化任务体参数匹配,命中规则连乘(EvalParamMultipliers)。 + // 旧缓存条目无此字段(zero value),不受影响。 + ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"` + + // 计费方案(pricing JSONB 顶层 scheme):空 = generic 通用引擎; + // 特殊方案的估算分发见 estimateTaskCost / LookupScheme。 + // 旧缓存条目无此字段(空串),不受影响 + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器,方案自行解析)。旧缓存条目无此字段(nil) + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` +} + +// perSecondWildcard 矩阵兜底价键:规格未命中时使用 +const perSecondWildcard = "*" + +// BillingModeSpecial 特殊计费模式(与 per_second 平级):仅由特殊计费方案使用, +// pricing JSONB 顶层必须同时声明 scheme 键(写侧双向配对校验)。计费分发只看 +// Scheme 不看此值,它用于展示层区分「按秒计费」与「特殊方案组合计费」, +// 不出现在通用计费模式下拉中(只由方案专属编辑器提交)。 +const BillingModeSpecial = "special" + +// maxTaskDurationSeconds per_second 计费的时长上限(秒)。 +// 时长来自用户请求(spec.duration 经 ratios 流入,metadata 路径绕过请求层校验), +// 钳制防「天价 duration 刷预扣漏洞/恶意配错」;正常视频模型上限远低于此值。 +const maxTaskDurationSeconds = 120.0 + +// defaultTaskDurationSeconds per_second 计费缺省时长(秒):请求未携带时长信号时的估算基准 +const defaultTaskDurationSeconds = 5.0 + +// LookupPerSecondPrice 按秒计费矩阵查价:spec 未命中依次回退 "*" 兜底价、矩阵最低价。 +// 返回 0 表示矩阵为空/全零(调用方按未配价兜底处理)。 +func LookupPerSecondPrice(prices map[string]float64, spec string) float64 { + if len(prices) == 0 { + return 0 + } + if p, ok := prices[spec]; ok && p > 0 { + return p + } + // 大小写不敏感兜底:配置键形态因供应商词汇而异("720p" vs "720P"), + // 语义同档应优先于 "*" 通配回退,防止大写上报查不中而按兜底价计费 + if lower := strings.ToLower(spec); lower != "" { + for k, p := range prices { + if strings.ToLower(k) == lower && p > 0 { + return p + } + } + } + if p, ok := prices[perSecondWildcard]; ok && p > 0 { + return p + } + // 无 "*" 且未命中:取矩阵最低价兜底,保证新规格上线未配价不 fail + min := 0.0 + for _, p := range prices { + if p > 0 && (min == 0 || p < min) { + min = p + } + } + return min } // ClearTenantPriceCache 清除租户的所有模型价格缓存 @@ -138,45 +204,20 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill return nil, gerror.Newf("model not found: %s", modelName) } - // 2. 从 mdl_pricing 获取定价(全行读取:锚点行 min_tokens=0 是默认价与时段配置载体, - // 其余行是平台阶梯档位。此前只读锚点行导致平台阶梯不参与计费,退化为第一档平价) + // 2. 从 mdl_pricing 获取定价:每模型一行(uk_mdl_pricing_model),pricing JSONB 为唯一真相 type pricingRow struct { - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - BillingMode string `json:"billing_mode"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - CacheReadPrice float64 `json:"cache_read_price"` - CacheCreationPrice float64 `json:"cache_creation_price"` - TimeSegments string `json:"time_segments"` + BillingMode string `json:"billing_mode"` + Pricing string `json:"pricing"` // JSONB 计费详情(按 billing_mode 单模式存储) } - var pricingRows []pricingRow + var pricing *pricingRow err = dao.MdlPricing.Ctx(ctx). Where("model_id", model.ID). - OrderAsc("min_tokens"). - Scan(&pricingRows) + Scan(&pricing) if err != nil { return nil, gerror.Wrapf(err, "query model pricing") } - // 锚点行(默认价)与平台阶梯(含锚点行作为第一档,与租户端 buildTiers 口径一致) - var pricing *pricingRow - var platformTiers []pricingTierRow - for i := range pricingRows { - row := &pricingRows[i] - if row.MinTokens == 0 { - pricing = row - } - platformTiers = append(platformTiers, pricingTierRow{ - MinTokens: row.MinTokens, - MaxTokens: row.MaxTokens, - InputPrice: row.InputPrice, - OutputPrice: row.OutputPrice, - }) - } - billingMode := "token" inputPrice := 0.0 outputPrice := 0.0 @@ -185,20 +226,48 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill var perRequestPrice float64 cacheReadPrice := 0.0 cacheCreationPrice := 0.0 + var platformTiers []pricingTierRow + var perSecondPrices map[string]float64 + var timeSegments []TimeSegment + var paramMultipliers []ParamRule + var schemeName string + var schemeConfig json.RawMessage if pricing != nil { if pricing.BillingMode != "" { billingMode = pricing.BillingMode } - inputPrice = pricing.InputPrice - outputPrice = pricing.OutputPrice - baseInputPrice = pricing.InputPrice - baseOutputPrice = pricing.OutputPrice - if pricing.PerRequestPrice != nil { - perRequestPrice = *pricing.PerRequestPrice + // pricing JSONB 解析(唯一真相;空/解析失败按未配价处理,validatePricingConfigured fail-closed 拦截) + if pricing.Pricing != "" && pricing.Pricing != "null" { + blob := &PricingBlob{} + if err := json.Unmarshal([]byte(pricing.Pricing), blob); err != nil { + g.Log().Warningf(ctx, "billing: 模型 %s 定价 JSON 解析失败,按未配价处理: %v", modelName, err) + } else { + if blob.InputPrice != nil { + inputPrice = *blob.InputPrice + baseInputPrice = *blob.InputPrice + } + if blob.OutputPrice != nil { + outputPrice = *blob.OutputPrice + baseOutputPrice = *blob.OutputPrice + } + if blob.CacheReadPrice != nil { + cacheReadPrice = *blob.CacheReadPrice + } + if blob.CacheCreationPrice != nil { + cacheCreationPrice = *blob.CacheCreationPrice + } + if blob.Price != nil { + perRequestPrice = *blob.Price + } + platformTiers = blob.Tiers + perSecondPrices = blob.Prices + timeSegments = blob.TimeSegments + paramMultipliers = blob.ParamMultipliers + schemeName = blob.Scheme + schemeConfig = blob.SchemeConfig + } } - cacheReadPrice = pricing.CacheReadPrice - cacheCreationPrice = pricing.CacheCreationPrice } // 3. 查租户独立价格(mdl_tenant_models) @@ -232,7 +301,9 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill if tm != nil && tm.Enabled { billingSource = "tenant_custom" - if tm.BillingMode != nil && *tm.BillingMode != "" { + // 特殊计费方案模型的 billing_mode 由平台方案决定(special),租户级模式覆盖无效: + // 方案的输出生成组件消费平台 per_second 矩阵,租户改模式只会造成日志标签与计费口径错位 + if schemeName == "" && tm.BillingMode != nil && *tm.BillingMode != "" { billingMode = *tm.BillingMode } @@ -272,21 +343,12 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill } } - // 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(mdl_pricing 档位行)。 + // 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(pricing JSONB tiers 数组)。 // 计费模式非 tiered 时 computeCost 不会读取 CustomTiers,无副作用 if len(customTiers) == 0 { customTiers = platformTiers } - // 2.6 时段定价(锚点行 JSONB;解析失败按默认价处理并告警,不阻断计费) - var timeSegments []TimeSegment - if pricing != nil && pricing.TimeSegments != "" && pricing.TimeSegments != "null" { - if err := json.Unmarshal([]byte(pricing.TimeSegments), &timeSegments); err != nil { - g.Log().Warningf(ctx, "billing: 模型 %s 时段定价配置解析失败,按默认价处理: %v", modelName, err) - timeSegments = nil - } - } - // 3.5 级别折扣 fallback:当租户×模型维度未设置倍率时,使用租户级别的 price_multiplier if tenantMultiplier == 1.0 { levelMultiplier := GetLevelPriceMultiplier(ctx, tenantID) @@ -330,6 +392,10 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill MaxOutputTokens: model.MaxOutputTokens, CustomTiers: customTiers, TimeSegments: timeSegments, + PerSecondPrices: perSecondPrices, + ParamMultipliers: paramMultipliers, + Scheme: schemeName, + SchemeConfig: schemeConfig, } // 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考) @@ -588,6 +654,10 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error { if pricing.PerRequestPrice <= 0 { return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (per_request price not set)", modelName) } + case BillingModeSpecial, "per_second": + if LookupPerSecondPrice(pricing.PerSecondPrices, perSecondWildcard) <= 0 { + return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (%s prices not set)", modelName, pricing.BillingMode) + } default: // token / tiered if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && !tiersHavePrice(pricing.CustomTiers) { return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s", modelName) @@ -596,6 +666,144 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error { return nil } +// PricingItemInput BuildPricingBlob 的输入项(管理端 SetModelPricing / 模型导入共用, +// 与 api/admin/v1.PricingItem 字段一一对应但避免 billing 反向依赖 api 层) +type PricingItemInput struct { + BillingMode string + MinTokens int64 + MaxTokens *int64 + InputPrice float64 + OutputPrice float64 + PerRequestPrice *float64 + CacheReadPrice float64 + CacheCreationPrice float64 + PerSecondPrices map[string]float64 +} + +// BuildPricingBlob 从全量替换语义的定价项列表构造 pricing JSONB(按 billing_mode 单模式存储)。 +// 校验规则(设计文档 3.4):金额非负;per_second 矩阵非空且至少一档正价(建议配 "*" 兜底), +// 仅允许单锚点行;tiered 阶梯按 min_tokens 升序整理。 +func BuildPricingBlob(items []PricingItemInput) (*PricingBlob, error) { + if len(items) == 0 { + return &PricingBlob{}, nil + } + mode := items[0].BillingMode + anchor := items[0] + + switch mode { + case BillingModeSpecial, "per_second": + if len(items) > 1 { + return nil, gerror.Newf("%s 计费模式只允许一行定价(分辨率矩阵)", mode) + } + if anchor.MinTokens != 0 { + return nil, gerror.Newf("%s 计费模式要求 min_tokens=0 的定价行", mode) + } + if len(anchor.PerSecondPrices) == 0 { + return nil, gerror.Newf("%s 计费模式要求配置分辨率单价矩阵(per_second_prices)", mode) + } + hasPositive := false + for spec, price := range anchor.PerSecondPrices { + if spec == "" { + return nil, gerror.New("per_second_prices 存在空规格键") + } + if price < 0 { + return nil, gerror.Newf("per_second_prices[%s] 单价为负数", spec) + } + if price > 0 { + hasPositive = true + } + } + if !hasPositive { + return nil, gerror.New("per_second_prices 至少需要一档正价(建议额外配置 \"*\" 兜底价)") + } + return &PricingBlob{Unit: "second", Prices: anchor.PerSecondPrices}, nil + + case "per_request": + blob := &PricingBlob{} + if anchor.PerRequestPrice != nil { + blob.Price = anchor.PerRequestPrice + } + return blob, nil + + case "tiered": + blob := &PricingBlob{ + Tiers: make([]pricingTierRow, 0, len(items)), + CacheReadPrice: positivePtr(anchor.CacheReadPrice), + CacheCreationPrice: positivePtr(anchor.CacheCreationPrice), + } + for _, item := range items { + blob.Tiers = append(blob.Tiers, pricingTierRow{ + MinTokens: item.MinTokens, + MaxTokens: item.MaxTokens, + InputPrice: item.InputPrice, + OutputPrice: item.OutputPrice, + CacheReadPrice: positivePtr(item.CacheReadPrice), + CacheCreationPrice: positivePtr(item.CacheCreationPrice), + }) + } + return blob, nil + + default: // token + return &PricingBlob{ + InputPrice: positivePtr(anchor.InputPrice), + OutputPrice: positivePtr(anchor.OutputPrice), + CacheReadPrice: positivePtr(anchor.CacheReadPrice), + CacheCreationPrice: positivePtr(anchor.CacheCreationPrice), + }, nil + } +} + +// positivePtr 正值取指针(0/负值返回 nil,JSONB 中省略该键由旧列兜底语义对齐) +func positivePtr(v float64) *float64 { + if v > 0 { + return &v + } + return nil +} + +// ParsePricingBlob 解析 mdl_pricing.pricing JSONB(管理端/租户端展示路径复用)。 +// 空串/null/解析失败返回 nil,调用方按未配价处理。 +func ParsePricingBlob(raw string) *PricingBlob { + if raw == "" || raw == "null" { + return nil + } + blob := &PricingBlob{} + if err := json.Unmarshal([]byte(raw), blob); err != nil { + return nil + } + return blob +} + +// PricingBlob mdl_pricing.pricing JSONB 的内存形态(按 billing_mode 单模式存储,单一真相)。 +// 设计约束(docs/模型定价存储JSON化与按秒计费设计.md): +// - 数值一律 float64 number(禁止 decimal 直接 marshal 进 JSONB——会变带引号字符串); +// - time_segments 并入顶层(横切所有模式); +// - 租户覆盖不逐格配:mdl_tenant_models.multiplier 作用于整个矩阵。 +type PricingBlob struct { + // token 模式 + InputPrice *float64 `json:"input_price,omitempty"` + OutputPrice *float64 `json:"output_price,omitempty"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` + CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"` + // tiered 模式:档位数组(min_tokens 升序,最后一档 max_tokens 可为 null) + Tiers []pricingTierRow `json:"tiers,omitempty"` + // per_request 模式 + Price *float64 `json:"price,omitempty"` + // per_second 模式:规格 → 每秒单价矩阵,"*" 为兜底价 + Unit string `json:"unit,omitempty"` + Prices map[string]float64 `json:"prices,omitempty"` + // 横切:时段定价 + TimeSegments []TimeSegment `json:"time_segments,omitempty"` + // 横切:参数倍率(按归一化任务体参数匹配,命中规则连乘;所有计费模式共享) + ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"` + // 计费方案:特殊计费模型声明引用的方案名(空 = generic 通用引擎), + // 前端按此字段分发定价编辑器。导出格式不含该字段(特殊方案定价不随导入导出迁移) + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器):schema 由方案实现自定义并自行解析校验, + // 引擎与通用编辑器不理解其内容,防止主干结构随方案膨胀 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` +} + // tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。 // 平台阶梯兜底后 CustomTiers 可能恒非空,全 0 价阶梯不得视为「已配置定价」放行 func tiersHavePrice(tiers []pricingTierRow) bool { @@ -607,12 +815,16 @@ func tiersHavePrice(tiers []pricingTierRow) bool { return false } -// pricingTierRow 定价阶梯行(绝对价格) +// pricingTierRow 定价阶梯行(绝对价格)。 +// 逐档缓存价为可选键(omitempty,旧数据缺键 unmarshal 为 nil,无需迁移): +// 编辑器逐档缓存价落库于此;计费引擎的缓存 token 计费仍取 blob 顶层锚点缓存价,不读档内字段 type pricingTierRow struct { - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` + MinTokens int64 `json:"min_tokens"` + MaxTokens *int64 `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` + CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"` } // effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。 diff --git a/internal/logic/billing/pricing_blob_test.go b/internal/logic/billing/pricing_blob_test.go new file mode 100644 index 0000000..8bce9b4 --- /dev/null +++ b/internal/logic/billing/pricing_blob_test.go @@ -0,0 +1,190 @@ +package billing + +import ( + "encoding/json" + "testing" +) + +// TestBuildPricingBlob_PerSecond 按秒模式:矩阵校验 + JSON 往返(数值必须是 number 而非带引号字符串) +func TestBuildPricingBlob_PerSecond(t *testing.T) { + blob, err := BuildPricingBlob([]PricingItemInput{{ + BillingMode: "per_second", + PerSecondPrices: map[string]float64{"480p": 0.25, "720p": 0.5, "*": 0.5}, + }}) + if err != nil { + t.Fatalf("build: %v", err) + } + if blob.Unit != "second" || blob.Prices["720p"] != 0.5 { + t.Fatalf("unexpected blob: %+v", blob) + } + + // JSONB 契约:数值序列化为 number(decimal 直接 marshal 会变字符串,此处为 float64) + raw, _ := json.Marshal(blob) + if !json.Valid(raw) { + t.Fatalf("invalid json: %s", raw) + } + var back struct { + Unit string `json:"unit"` + Prices map[string]float64 `json:"prices"` + } + if err := json.Unmarshal(raw, &back); err != nil || back.Prices["720p"] != 0.5 { + t.Fatalf("json roundtrip failed: %v %v", err, back) + } +} + +func TestBuildPricingBlob_PerSecondValidation(t *testing.T) { + // 多行拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "per_second", PerSecondPrices: map[string]float64{"*": 0.5}}, + {BillingMode: "per_second", MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}}, + }); err == nil { + t.Fatal("expected multi-row rejection") + } + // 空矩阵拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{{BillingMode: "per_second"}}); err == nil { + t.Fatal("expected empty matrix rejection") + } + // 全零矩阵拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "per_second", PerSecondPrices: map[string]float64{"720p": 0}}, + }); err == nil { + t.Fatal("expected all-zero matrix rejection") + } + // 负价拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "per_second", PerSecondPrices: map[string]float64{"720p": -0.1, "*": 0.5}}, + }); err == nil { + t.Fatal("expected negative price rejection") + } + // 空规格键拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "per_second", PerSecondPrices: map[string]float64{"": 0.5}}, + }); err == nil { + t.Fatal("expected empty spec key rejection") + } +} + +// TestBuildPricingBlob_Special 特殊计费模式:与 per_second 同构的矩阵校验 +// (special 是方案模型的 billing_mode 标识,定价形态仍是按秒矩阵) +func TestBuildPricingBlob_Special(t *testing.T) { + // 正向:矩阵照常构造 + blob, err := BuildPricingBlob([]PricingItemInput{{ + BillingMode: BillingModeSpecial, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6, "*": 0.35}, + }}) + if err != nil { + t.Fatalf("build: %v", err) + } + if blob.Unit != "second" || blob.Prices["2K"] != 0.6 { + t.Fatalf("unexpected blob: %+v", blob) + } + + // 多行拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"*": 0.5}}, + {BillingMode: BillingModeSpecial, MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}}, + }); err == nil { + t.Fatal("expected multi-row rejection") + } + // min_tokens ≠ 0 拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}}, + }); err == nil { + t.Fatal("expected min_tokens rejection") + } + // 空矩阵拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{{BillingMode: BillingModeSpecial}}); err == nil { + t.Fatal("expected empty matrix rejection") + } + // 全零/负价拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"768P": -0.1}}, + }); err == nil { + t.Fatal("expected non-positive matrix rejection") + } +} + +// TestBuildPricingBlob_TokenAndTiered token/tiered 模式构造与阶梯数组 +func TestBuildPricingBlob_TokenAndTiered(t *testing.T) { + blob, err := BuildPricingBlob([]PricingItemInput{{ + BillingMode: "token", + InputPrice: 15, + OutputPrice: 75, + CacheReadPrice: 1.5, + }}) + if err != nil || blob.InputPrice == nil || *blob.InputPrice != 15 { + t.Fatalf("token blob: %+v err=%v", blob, err) + } + + max := int64(100000) + blob, err = BuildPricingBlob([]PricingItemInput{ + {BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5}, + {BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60}, + }) + if err != nil || len(blob.Tiers) != 2 { + t.Fatalf("tiered blob: %+v err=%v", blob, err) + } + if blob.Tiers[1].MaxTokens != nil { + t.Fatalf("last tier max_tokens should be null: %+v", blob.Tiers[1]) + } +} + +// TestBuildPricingBlob_ZeroPricesOmitted 0 值字段省略(omitempty):JSON 中无该键, +// 加载侧旧列兜底语义对齐(0 价模型读 JSON 得 0,与旧行为一致) +func TestBuildPricingBlob_ZeroPricesOmitted(t *testing.T) { + blob, err := BuildPricingBlob([]PricingItemInput{{BillingMode: "token"}}) + if err != nil { + t.Fatalf("build: %v", err) + } + raw, _ := json.Marshal(blob) + if string(raw) != "{}" { + t.Fatalf("expected empty blob json, got %s", raw) + } +} + +// TestBuildPricingBlob_TieredPerTierCachePrices 阶梯逐档缓存价:档内键随行落库, +// 顶层锚点缓存价照旧写入(计费引擎的缓存 token 计费读顶层,不读档内字段)。 +// JSONB 往返后两处都完整保留;旧格式(档内无缓存键)unmarshal 为 nil 不报错。 +func TestBuildPricingBlob_TieredPerTierCachePrices(t *testing.T) { + max := int64(100000) + blob, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5, CacheCreationPrice: 18.75}, + {BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60, CacheReadPrice: 1.2, CacheCreationPrice: 15}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + // 顶层锚点缓存价保留(计费引擎读取口径) + if blob.CacheReadPrice == nil || *blob.CacheReadPrice != 1.5 || blob.CacheCreationPrice == nil || *blob.CacheCreationPrice != 18.75 { + t.Fatalf("anchor cache prices lost: %+v", blob) + } + // 逐档缓存价落行 + if blob.Tiers[0].CacheReadPrice == nil || *blob.Tiers[0].CacheReadPrice != 1.5 { + t.Fatalf("tier0 cache_read_price lost: %+v", blob.Tiers[0]) + } + if blob.Tiers[1].CacheReadPrice == nil || *blob.Tiers[1].CacheReadPrice != 1.2 { + t.Fatalf("tier1 cache_read_price lost: %+v", blob.Tiers[1]) + } + + // JSONB 往返(mdl_pricing.pricing 的真实存取路径) + raw, err := json.Marshal(blob) + if err != nil { + t.Fatalf("marshal: %v", err) + } + parsed := ParsePricingBlob(string(raw)) + if parsed == nil { + t.Fatal("parse failed") + } + if parsed.CacheReadPrice == nil || *parsed.CacheReadPrice != 1.5 { + t.Fatalf("roundtrip anchor cache lost: %+v", parsed) + } + if parsed.Tiers[1].CacheCreationPrice == nil || *parsed.Tiers[1].CacheCreationPrice != 15 { + t.Fatalf("roundtrip tier1 cache_creation lost: %+v", parsed.Tiers[1]) + } + + // 旧格式(档内无缓存键、顶层有锚点):unmarshal 不报错,档内为 nil + legacy := ParsePricingBlob(`{"tiers":[{"min_tokens":0,"input_price":15,"output_price":75}],"cache_read_price":1.5}`) + if legacy == nil || legacy.Tiers[0].CacheReadPrice != nil || legacy.CacheReadPrice == nil || *legacy.CacheReadPrice != 1.5 { + t.Fatalf("legacy blob parse broken: %+v", legacy) + } +} diff --git a/internal/logic/billing/pricing_migration_verify_test.go b/internal/logic/billing/pricing_migration_verify_test.go new file mode 100644 index 0000000..efc65a1 --- /dev/null +++ b/internal/logic/billing/pricing_migration_verify_test.go @@ -0,0 +1,79 @@ +//go:build e2e + +// 定价 JSON 化完整性校验(需真实 DB)。默认 go test 不编译本文件(与 e2e_time_segment_test 同策略: +// 无条件引入 pgsql 驱动会让默认单测连上真实库,billing_currency 等部署配置泄漏进单元测试)。 +// 跑法:go test -tags e2e -run TestPricingJSONComplete ./internal/logic/billing/ + +package billing + +import ( + "context" + "encoding/json" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" + "github.com/gogf/gf/v2/frame/g" +) + +// TestPricingJSONComplete contract 迁移(000021)后完整性校验: +// 每模型一行、pricing JSONB 非空且可解析、tiered 模式含阶梯数组。 +func TestPricingJSONComplete(t *testing.T) { + ctx := context.Background() + + // 1. 每模型恰好一行(uk 约束之外的双重确认) + dupVal, err := g.DB().GetValue(ctx, + `SELECT COUNT(*) FROM (SELECT model_id FROM mdl_pricing GROUP BY model_id HAVING count(*) > 1) d`) + if err != nil { + t.Fatalf("query dup rows: %v", err) + } + if n := dupVal.Int(); n > 0 { + t.Errorf("%d 个模型存在多行定价(contract 未收敛)", n) + } + + // 2. pricing JSONB 全部非空 + nullVal, err := g.DB().GetValue(ctx, + `SELECT COUNT(*) FROM mdl_pricing WHERE pricing IS NULL`) + if err != nil { + t.Fatalf("query null pricing: %v", err) + } + if n := nullVal.Int(); n > 0 { + t.Errorf("%d 行 pricing JSON 为空(唯一真相缺失,计费将按未配价 fail-closed)", n) + } + + // 3. JSON 可解析 + 模式字段完整性(tiered 必含 tiers 数组、per_second 必含矩阵) + rows, err := g.DB().GetAll(ctx, + `SELECT model_id, billing_mode, pricing FROM mdl_pricing WHERE pricing IS NOT NULL`) + if err != nil { + t.Fatalf("query rows: %v", err) + } + bad := 0 + for _, row := range rows { + blob := &PricingBlob{} + if err := json.Unmarshal([]byte(row["pricing"].String()), blob); err != nil { + t.Errorf("model_id=%v pricing JSON 解析失败: %v", row["model_id"], err) + bad++ + continue + } + switch row["billing_mode"].String() { + case "tiered": + if len(blob.Tiers) == 0 { + t.Errorf("model_id=%v tiered 模式 tiers 为空", row["model_id"]) + bad++ + } + case "per_second", BillingModeSpecial: + if len(blob.Prices) == 0 { + t.Errorf("model_id=%v %s 模式 prices 矩阵为空", row["model_id"], row["billing_mode"].String()) + bad++ + } + // special 必须与方案配对(pricing JSONB 顶层 scheme 键),裸 special 会在 + // 计费入口 fail-closed 拒绝请求 + if row["billing_mode"].String() == BillingModeSpecial && blob.Scheme == "" { + t.Errorf("model_id=%v special 模式缺少 scheme 声明", row["model_id"]) + bad++ + } + } + } + if bad > 0 { + t.Fatalf("%d 行 pricing JSON 内容异常", bad) + } +} diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index 71b90e8..fad89e1 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -326,6 +326,17 @@ func TestValidatePricingConfigured(t *testing.T) { pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, OutputPrice: 10.0}}}, wantErr: false, }, + { + // special 与 per_second 同走矩阵校验:token 价全 0 不构成「已配置」 + name: "special 空矩阵拒绝(防 fail-closed 误放行)", + pricing: &PricingResult{BillingMode: BillingModeSpecial}, + wantErr: true, + }, + { + name: "special 有矩阵放行", + pricing: &PricingResult{BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"*": 0.35}}, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/logic/billing/ratelimit.go b/internal/logic/billing/ratelimit.go index da78aaf..64ca3e1 100644 --- a/internal/logic/billing/ratelimit.go +++ b/internal/logic/billing/ratelimit.go @@ -26,6 +26,7 @@ type RateLimitConfig struct { TenantQPS int // 租户级 QPS 限制(默认 1000) UserQPS int // 用户级 QPS 限制(默认 100) KeyQPS int // Key 级 QPS 限制(默认 60) + SystemConc int // 系统级并发限制(默认 0,不限制)——全平台在途请求总闸门 TenantConc int // 租户级并发限制(默认 0,不限制) } @@ -35,6 +36,7 @@ var DefaultRateLimitConfig = RateLimitConfig{ TenantQPS: 1000, UserQPS: 100, KeyQPS: 60, + SystemConc: 0, TenantConc: 0, } @@ -64,6 +66,9 @@ func LoadRateLimitConfig(ctx context.Context) RateLimitConfig { if v := cfg.GetInt(ctx, "key_qps_limit"); v > 0 { c.KeyQPS = v } + if v := cfg.GetInt(ctx, "global_concurrency_limit"); v > 0 { + c.SystemConc = v + } if v := cfg.GetInt(ctx, "tenant_concurrency_limit"); v > 0 { c.TenantConc = v } @@ -207,20 +212,20 @@ func withKeyQPS(config RateLimitConfig, keyQPS int) RateLimitConfig { } // --------------------------------------------------------------------------- -// 并发限流:单个 Lua 脚本完成 2 级获取 + 失败回滚(租户 + 模型) +// 并发限流:单个 Lua 脚本完成 3 级获取 + 失败回滚(系统 + 租户 + 模型) // --------------------------------------------------------------------------- -// acquireConcurrentLua 一次 EVAL 完成 2 级并发许可获取(租户 + 模型)。 -// KEYS[1..2]: tenant/model 的 Redis key -// ARGV[1..2]: 各级并发限制(0 表示不检查该级) -// ARGV[3]: EXPIRE 秒数 +// acquireConcurrentLua 一次 EVAL 完成 3 级并发许可获取(系统 + 租户 + 模型)。 +// KEYS[1..3]: system/tenant/model 的 Redis key +// ARGV[1..3]: 各级并发限制(0 表示不检查该级) +// ARGV[4]: EXPIRE 秒数 // // 逐级尝试获取,任一级失败则回滚已获取的许可。 // 返回 1=成功, 0=失败。 var acquireConcurrentLua = ` -local keys = {KEYS[1], KEYS[2]} -local limits = {tonumber(ARGV[1]), tonumber(ARGV[2])} -local expire = tonumber(ARGV[3]) +local keys = {KEYS[1], KEYS[2], KEYS[3]} +local limits = {tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])} +local expire = tonumber(ARGV[4]) local acquired = {} local function tryAcquire(i) @@ -243,16 +248,17 @@ local function tryAcquire(i) return true end -if not tryAcquire(1) then return 0 end -if not tryAcquire(2) then return 0 end +for i = 1, 3 do + if not tryAcquire(i) then return 0 end +end return 1 ` -// releaseConcurrentLua 一次 EVAL 释放 2 级并发许可(租户 + 模型)。 +// releaseConcurrentLua 一次 EVAL 释放 3 级并发许可(系统 + 租户 + 模型)。 // ARGV[1] = expire 秒数(用于 SET 后恢复 TTL) var releaseConcurrentLua = ` local expire = tonumber(ARGV[1]) -for i = 1, 2 do +for i = 1, 3 do local v = redis.call("DECR", KEYS[i]) if v < 0 then redis.call("SET", KEYS[i], 0) @@ -262,11 +268,14 @@ end return 1 ` -// AcquireConcurrent 获取并发许可(两级:租户→模型) +// AcquireConcurrent 获取并发许可(三级:系统→租户→模型) +// 系统级并发限制来自全局配置 global_concurrency_limit(0 表示不限), +// 是全平台在途请求总闸门(Redis 计数跨实例共享,多副本天然生效); // 租户级并发限制优先使用 tnt_tenants.max_concurrency,0 表示不限; // 模型级并发限制使用 mdl_tenant_models.max_concurrency,NULL 表示不限; // 若未设置则回退到全局配置 tenant_concurrency_limit。 func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, userID, apiKeyID int64, modelName string) bool { + systemKey := "conc:system" tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID) modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName) @@ -274,9 +283,9 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us tenantLimit := getTenantConcurrencyLimit(ctx, tenantID) modelLimit := getModelConcurrencyLimit(ctx, tenantID, modelName) - result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 2, - tenantKey, modelKey, - tenantLimit, modelLimit, + result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 3, + systemKey, tenantKey, modelKey, + config.SystemConc, tenantLimit, modelLimit, 300) // EXPIRE 秒数 if err != nil { return true // Redis 不可用,允许通过 @@ -285,14 +294,15 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us return result.Int() == 1 } -// ReleaseConcurrent 释放并发许可(租户 + 模型两级) -// 单次 EVAL 释放所有 2 个 key。 +// ReleaseConcurrent 释放并发许可(系统 + 租户 + 模型三级) +// 单次 EVAL 释放所有 3 个 key。 func ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) { + systemKey := "conc:system" tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID) modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName) - g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 2, - tenantKey, modelKey, + g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 3, + systemKey, tenantKey, modelKey, 300) // EXPIRE 秒数 } diff --git a/internal/logic/billing/reconciliation.go b/internal/logic/billing/reconciliation.go index 330fa28..b13d0e8 100644 --- a/internal/logic/billing/reconciliation.go +++ b/internal/logic/billing/reconciliation.go @@ -185,7 +185,7 @@ func reconcileFrozenBalance(ctx context.Context) { // checkOneWalletFrozenDrift 比对单个租户的 Redis/DB 冻结值,超窗漂移告警 func checkOneWalletFrozenDrift(ctx context.Context, tenantID int64, materializeLag time.Duration) { - _, frozenMicro, exists, err := readWalletHash(ctx, tenantID) + _, frozenMicro, _, _, exists, err := readWalletHash(ctx, tenantID) if err != nil || !exists { return } diff --git a/internal/logic/billing/scheme.go b/internal/logic/billing/scheme.go new file mode 100644 index 0000000..acfd770 --- /dev/null +++ b/internal/logic/billing/scheme.go @@ -0,0 +1,233 @@ +package billing + +import ( + "encoding/json" + "sync" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// ============================================================ +// 计费方案(BillingScheme)扩展点 +// ============================================================ +// 背景:部分模型的计费规则无法归约为「单价 × 数量」的结构化配置(条件切价、 +// 组合计费、依赖上游私有字段等)。为此把「任务预扣估算」的定价解释权抽象为 +// 可注册的方案接口:模型在 pricing JSONB 顶层 "scheme" 键声明引用,未声明走 +// generic(现有通用引擎),计费行为与抽象前完全一致。 +// +// 设计约束(新增方案必须遵守,否则账目口径漂移): +// 1. 方案只负责组件费用计算:EstimateTaskCost 返回已乘租户乘数 × 时段乘数的 +// 金额;ratios 附加乘数(param_multiplier 等)、minCost 钳制与 RoundMoney +// 由通用层统一应用——全站结算口径、快照与对账不因方案而分叉; +// 2. 方案私有配置存 pricing JSONB 顶层 "scheme_config"(不透明容器),由方案 +// 自行解析校验,不得向 PricingBlob 增加方案私有字段(主干文件不随方案膨胀, +// 二开新增方案不改任何既有文件); +// 3. 优先消费归一化计费上下文(ratios,adaptor 经 EstimateBilling 上报); +// taskBody 为归一化任务体,仅确实需要原始请求体的方案使用(nil = 不可用); +// 4. 自定义方案建议 "custom:" 前缀命名,避免与内置方案撞名。 +// +// 注册模式与 relay/channel 的供应商适配器一致:方案实现放独立文件/目录, +// init() 中 RegisterScheme 自注册,新增方案不改本文件与 task_billing.go。 + +// SchemeGeneric 通用计费方案名(pricing JSONB 未声明 scheme 时的默认值) +const SchemeGeneric = "generic" + +// BillingScheme 计费方案接口:特殊计费模型的任务预扣估算与结算重算扩展点。 +// 接口签名视为公共契约(主干与二开方案的边界),保持稳定;修改需评估所有已注册方案。 +type BillingScheme interface { + // Name 方案名(唯一,与 pricing JSONB scheme 键对应) + Name() string + + // EstimateTaskCost 任务预扣估算(纯函数,不触 DB/缓存,便于单测)。 + // 返回已含租户乘数 × 时段乘数、未应用 ratios 附加乘数的费用; + // 附加乘数、minCost 钳制与 RoundMoney 由 estimateTaskCost 骨架统一应用。 + EstimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal + + // SettleTaskCost 任务成功结算口径(纯函数):按上游 usage 的素材计量计算最终费用。 + // 素材计量是「预扣时未知、生成后官方返回」的计费依据(如输入视频时长、图片张数); + // usage 为 nil 表示上游未提供,方案应回退 EstimateTaskCost 的预扣口径(多退少补兜底)。 + // 返回值口径与 EstimateTaskCost 一致(含租户/时段乘数,不含 ratios 附加乘数)。 + SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal + + // ValidateSchemeConfig 校验方案私有配置(pricing JSONB scheme_config), + // 管理端保存定价时 fail-fast 调用;generic 方案不接受非空配置。 + ValidateSchemeConfig(cfg json.RawMessage) error +} + +// schemeRegistry 方案注册表(init 自注册,同 relay/channel 适配器模式) +var schemeRegistry sync.Map // name → BillingScheme + +// RegisterScheme 注册计费方案(方案实现的 init 中调用)。 +// 空名/重名直接 panic:注册发生在进程启动期,快死优于带病运行。 +func RegisterScheme(s BillingScheme) { + if s == nil || s.Name() == "" { + panic("billing: register nil or empty-name scheme") + } + if _, loaded := schemeRegistry.LoadOrStore(s.Name(), s); loaded { + panic("billing: duplicate scheme registration: " + s.Name()) + } +} + +// LookupScheme 按方案名查注册表:空名/未注册一律回落 generic。 +// 未注册兜底是纯函数层的防御(写路径已由 SchemeRegistered 拦截、预扣入口 +// fail-closed 拒绝,正常流量到不了这里),保证估算函数不因配置异常而 panic。 +func LookupScheme(name string) BillingScheme { + if name != "" { + if s, ok := schemeRegistry.Load(name); ok { + return s.(BillingScheme) + } + } + if s, ok := schemeRegistry.Load(SchemeGeneric); ok { + return s.(BillingScheme) + } + return GenericScheme{} +} + +// SchemeRegistered 方案是否已注册。管理端保存定价前校验:未注册的方案名 +// 不得写入配置(防止配置引用不存在的实现,运行期才暴露成计费异常)。 +func SchemeRegistered(name string) bool { + _, ok := schemeRegistry.Load(name) + return ok +} + +func init() { + RegisterScheme(GenericScheme{}) +} + +// GenericScheme 通用计费方案:token / per_request / per_second / tiered 的 +// 现有通用引擎(自 estimateTaskCost 分支逻辑原样搬迁,行为不变)。 +type GenericScheme struct{} + +func (GenericScheme) Name() string { return SchemeGeneric } + +func (GenericScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + if len(cfg) > 0 && string(cfg) != "null" { + return gerror.New("通用计费方案不接受 scheme_config(仅特殊方案支持私有配置)") + } + return nil +} + +// SettleTaskCost 通用引擎的结算口径: +// - per_second(按秒计费):上游返回实际输出秒数(usage.OutputSeconds > 0)时按 +// 「实际秒数 × 矩阵单价 × 租户乘数 × 时段乘数」结算,多退少补——查价键优先 +// usage.Resolution(实际输出分辨率,如阿里 wan3.0 usage.SR),回退 ratios 的 +// spec.resolution(提交时请求档位)。实际时长与请求时长不一致(智能时长模式、 +// 上游按内容微调)时以官方计量为准;usage 缺失回退预扣口径。 +// - 其余模式(token / per_request / tiered)无素材计量语义:忽略 usage,回退预扣口径 +// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。 +func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal { + if pricing.BillingMode == "per_second" && usage != nil && usage.OutputSeconds > 0 { + duration := usage.OutputSeconds + // 结算秒数钳制上限与预扣一致,防御异常大值刷扣 + if duration > maxTaskDurationSeconds { + duration = maxTaskDurationSeconds + } + spec, _ := ratioString(ratios, "spec.resolution") + if usage.Resolution != "" { + spec = usage.Resolution + } + if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { + return NewFromFloat(price). + Mul(NewFromFloat(duration)). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + } + // 矩阵为空/全零:落到下方回退(占位/预扣口径),与预扣分支的兜底行为一致 + } + return g.EstimateTaskCost(pricing, ratios, nil) +} + +// wrapSchemeCost 方案费用统一收口:应用 ratios 附加乘数 → minCost 钳制 → RoundMoney。 +// 预扣估算(estimateTaskCost)与结算重算(RecalculateByMaterials)共用, +// 保证两个时点对同一方案的费用处理口径完全一致。 +func wrapSchemeCost(pricing *PricingResult, ratios map[string]any, cost decimal.Decimal) decimal.Decimal { + // 应用附加比率(video_input 折扣等):只乘 float 乘数值, + // 跳过 duration/resolution(已在时长类分支消费)与 spec.*(规格事实值非乘数) + cost = applyRatioMultipliers(cost, ratios, "duration", "resolution") + + minCost := NewFromFloat(0.01) + if cost.LessThan(minCost) { + cost = minCost + } + return RoundMoney(cost) +} + +// EstimateTaskCost 通用引擎的任务预扣估算。计费口径按任务类型分流: +// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")× +// spec.duration 秒 × 租户乘数 × 时段乘数; +// - per_request(按次计费,图片/音乐等):直接取按次单价; +// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 +// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径); +// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价, +// 不再套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出 +// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。 +func (GenericScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { + var costD decimal.Decimal + switch { + case pricing.BillingMode == "per_second": + // 按秒计费:矩阵查价 × 时长 × 租户乘数 × 时段乘数 + duration := defaultTaskDurationSeconds + if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { + duration = d + } + // 时长来自用户请求,钳制上限防天价预扣(正常视频模型远低于该上限) + if duration > maxTaskDurationSeconds { + duration = maxTaskDurationSeconds + } + spec, _ := ratioString(ratios, "spec.resolution") + price := LookupPerSecondPrice(pricing.PerSecondPrices, spec) + if price <= 0 { + // 矩阵全零/为空:按未配价占位预扣,结算多退少补 + return NewFromFloat(imagePlaceholderPreDeduct) + } + costD = NewFromFloat(price). + Mul(NewFromFloat(duration)). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.BillingMode == "per_request": + // 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的 + // 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价, + // 预扣漏乘租户/时段折扣会让折扣租户按原价多扣 + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.OutputPrice > 0 && hasDurationSignal(ratios): + duration := 5.0 // 默认 5 秒 + if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 { + duration = d + } + resolutionMul := 2.25 // 默认 720p + if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 { + resolutionMul = r + } + + // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier + // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 + // 视频预扣三连乘全程 decimal 避免链式误差 + baseTokensPerSec := decimal.NewFromInt(10000) + durationD := NewFromFloat(duration) + resolutionMulD := NewFromFloat(resolutionMul) + million := decimal.NewFromInt(1_000_000) + + costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). + Div(million). + Mul(NewFromFloat(pricing.OutputPrice)). + Mul(NewFromFloat(pricing.TenantMultiplier)) + default: + // 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数, + // 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣, + // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 + // (见 sync_image_worker.settleSyncImageSuccess)。 + if pricing.PerRequestPrice > 0 { + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + } else { + costD = NewFromFloat(imagePlaceholderPreDeduct) + } + } + return costD +} diff --git a/internal/logic/billing/scheme_minimax_material.go b/internal/logic/billing/scheme_minimax_material.go new file mode 100644 index 0000000..0ce2d2a --- /dev/null +++ b/internal/logic/billing/scheme_minimax_material.go @@ -0,0 +1,231 @@ +package billing + +import ( + "encoding/json" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// ============================================================ +// MiniMax-H3 素材计费方案(custom:minimax-material) +// ============================================================ +// 适用:MiniMax-H3 / H3-Max 等按「输入素材 + 输出生成」组合计费的视频模型。 +// 计费公式(金额为本位币,统一乘 租户乘数 × 时段乘数): +// +// 总费用 = 输出生成费 + 输入图片费 + 输入视频费(输入音频免费,不配置即不计费) +// 输出生成费 = 输出秒数 × pricing JSONB per_second 矩阵[生成分辨率] +// 输入图片费 = max(0, 图片张数 - free_count) × price_per_unit +// 输入视频费 = 输入视频秒数 × per_second 矩阵[生成分辨率] +// +// 输入视频不单独定价:官方规则为「输入视频按时长、以输出分辨率对应单价计费」 +// (与生成视频同价),单价复用输出生成矩阵按生成分辨率查取,scheme_config 无 +// input_video 节(存量配置残留该节在保存时被校验拒绝,提示移除)。 +// +// 预扣 vs 结算: +// - 图片张数提交时可知(adaptor 上报 ratios 的 spec.input_image_count)→ 预扣即精确; +// - 输入视频常以 URL 提交、时长未知 → 提交含输入视频(spec.has_input_video)时预扣按 +// 生成长度上限 15s 冻结,结算以官方 usage(input_seconds)为准多退少补; +// - 输出秒数预扣按请求时长信号估,结算以 usage.output_seconds 为准。 +// 结算时 usage 某字段缺失(上游未返回)则回退 ratios 中提交时刻的对应 spec.* 事实值, +// 官方完全不返回素材计量时整体回退预扣口径(多退少补兜底)。 +// +// scheme_config(pricing JSONB 不透明容器,金额本位币;可不配置 = 图片免费, +// 输出与输入视频均按 per_second 矩阵计费): +// +// { +// "image": {"free_count": 5, "price_per_unit": 0.20} +// } + +// SchemeMiniMaxMaterial MiniMax 素材计费方案名(模型 pricing JSONB scheme 键引用) +const SchemeMiniMaxMaterial = "custom:minimax-material" + +// inputVideoMaxSeconds 输入视频计费时长上限(秒)。官方 usage 可信度高, +// 钳制仅防御异常大值刷扣;输出侧沿用 maxTaskDurationSeconds。 +const inputVideoMaxSeconds = 600.0 + +// inputVideoEstimateSeconds 输入视频预扣估算时长(秒):H3 生成本身上限 15s, +// 输入视频计费时长不会超过生成时长,提交含输入视频时按上限冻结, +// 避免 URL 素材时长未知导致预扣少冻结。 +const inputVideoEstimateSeconds = 15.0 + +// minimaxMaterialConfig 方案私有配置(输入视频按生成分辨率单价计费,无独立配置) +type minimaxMaterialConfig struct { + Image *minimaxImagePrice `json:"image,omitempty"` +} + +// minimaxImagePrice 输入图片单价:请求级免费额度 + 超出部分单价 +type minimaxImagePrice struct { + FreeCount int `json:"free_count"` // 免费张数(0 = 无免费额度) + PricePerUnit float64 `json:"price_per_unit"` // 超出部分单价(本位币/张) +} + +// minimaxMaterialScheme MiniMax 素材计费方案(无状态单例,配置从 pricing.SchemeConfig 读取) +type minimaxMaterialScheme struct{} + +func init() { + RegisterScheme(minimaxMaterialScheme{}) +} + +func (minimaxMaterialScheme) Name() string { return SchemeMiniMaxMaterial } + +// parseConfig 解析方案私有配置(解析失败按未配置处理:仅输出组件计费) +func (minimaxMaterialScheme) parseConfig(pricing *PricingResult) *minimaxMaterialConfig { + cfg := &minimaxMaterialConfig{} + if pricing == nil || len(pricing.SchemeConfig) == 0 { + return cfg + } + if err := json.Unmarshal(pricing.SchemeConfig, cfg); err != nil { + return &minimaxMaterialConfig{} + } + return cfg +} + +// ValidateSchemeConfig 校验 scheme_config(管理端保存 fail-fast): +// 配置可空(图片免费、仅按秒矩阵计费输出与输入视频的合法形态);配置时校验图片 +// 单价与免费额度非负;input_video 节已废弃(输入视频改按生成分辨率单价计费), +// 残留即拒绝,提示管理员移除后重存,避免旧单价静默失效造成计费口径突变。 +func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + if len(cfg) == 0 || string(cfg) == "null" { + return nil + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(cfg, &raw); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if _, ok := raw["input_video"]; ok { + return gerror.New("输入视频不再单独定价(按生成分辨率单价计费),请移除 scheme_config 中的 input_video 配置") + } + var c minimaxMaterialConfig + if err := json.Unmarshal(cfg, &c); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if c.Image != nil { + if c.Image.PricePerUnit < 0 { + return gerror.New("输入图片单价不能为负") + } + if c.Image.FreeCount < 0 { + return gerror.New("输入图片免费张数不能为负") + } + } + return nil +} + +// EstimateTaskCost 预扣估算:按秒组件(输出生成 + 输入视频,共用生成分辨率单价)+ +// 提交可知的图片组件(张数精确)。 +func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { + cfg := s.parseConfig(pricing) + spec, _ := ratioString(ratios, "spec.resolution") + total := decimal.Zero + + // 按秒组件(与通用按秒引擎共用查价与兜底链),矩阵未配置时输出与输入视频均不计费 + if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { + duration := defaultTaskDurationSeconds + if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { + duration = d + } + if duration > maxTaskDurationSeconds { + duration = maxTaskDurationSeconds + } + // 输出生成:时长 × 单价 + total = total.Add(NewFromFloat(price).Mul(NewFromFloat(duration))) + // 输入视频:估算时长(确知时长 > 含视频按生成长度上限 15s 估 > 0)× 同一单价, + // 结算以官方 usage 补差 + total = total.Add(inputVideoCost(price, estimateInputVideoSeconds(ratios))) + } + + // 输入图片:请求级免费额度扣减 + total = total.Add(s.imageCost(cfg, ratioFloatOr(ratios, "spec.input_image_count", 0))) + + return total.Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) +} + +// SettleTaskCost 结算口径:素材计量以官方 usage 为准,缺失字段回退 ratios 的提交时事实值, +// 官方完全不返回素材计量时整体回退预扣口径。 +func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal { + if usage == nil || (usage.InputVideoSeconds <= 0 && usage.InputImageCount <= 0 && usage.OutputSeconds <= 0) { + return s.EstimateTaskCost(pricing, ratios, nil) + } + + cfg := s.parseConfig(pricing) + spec, _ := ratioString(ratios, "spec.resolution") + total := decimal.Zero + + // 按秒组件单价:生成分辨率矩阵查价(输出与输入视频同价),矩阵未配置时为 0 + price := LookupPerSecondPrice(pricing.PerSecondPrices, spec) + + // 输出生成组件:优先官方 output_seconds,缺失回退请求时长信号(与预扣同源) + if price > 0 { + outputSecs := usage.OutputSeconds + if outputSecs <= 0 { + outputSecs = ratioFloatOr(ratios, "spec.duration", defaultTaskDurationSeconds) + } + if outputSecs > maxTaskDurationSeconds { + outputSecs = maxTaskDurationSeconds + } + total = total.Add(NewFromFloat(price).Mul(NewFromFloat(outputSecs))) + } + + // 输入图片:官方 input_image_count,缺失回退提交时上报的张数 + imageCount := float64(usage.InputImageCount) + if usage.InputImageCount <= 0 { + imageCount = ratioFloatOr(ratios, "spec.input_image_count", 0) + } + total = total.Add(s.imageCost(cfg, imageCount)) + + // 输入视频:官方 input_seconds,缺失回退预扣口径(确知时长 > 含视频按 15s 上限估 > 0) + inputSecs := usage.InputVideoSeconds + if inputSecs <= 0 { + inputSecs = estimateInputVideoSeconds(ratios) + } + total = total.Add(inputVideoCost(price, inputSecs)) + + return total.Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) +} + +// imageCost 输入图片费用:max(0, 张数 - 免费额度) × 单价 +func (minimaxMaterialScheme) imageCost(cfg *minimaxMaterialConfig, count float64) decimal.Decimal { + if cfg == nil || cfg.Image == nil || cfg.Image.PricePerUnit <= 0 || count <= float64(cfg.Image.FreeCount) { + return decimal.Zero + } + charged := count - float64(cfg.Image.FreeCount) + return NewFromFloat(charged).Mul(NewFromFloat(cfg.Image.PricePerUnit)) +} + +// inputVideoCost 输入视频费用:时长(钳制上限)× 生成分辨率单价。 +// 官方规则:输入视频按时长、以输出分辨率对应单价计费(与生成视频同价), +// 不单独定价、不区分输入视频自身分辨率(官方 usage 无该信息)。 +func inputVideoCost(price, seconds float64) decimal.Decimal { + if price <= 0 || seconds <= 0 { + return decimal.Zero + } + if seconds > inputVideoMaxSeconds { + seconds = inputVideoMaxSeconds + } + return NewFromFloat(seconds).Mul(NewFromFloat(price)) +} + +// estimateInputVideoSeconds 输入视频预扣估算时长(秒): +// adaptor 确知时长(spec.input_video_seconds)优先;否则提交含输入视频 +// (spec.has_input_video)按生成长度上限 15s 估;无输入视频为 0。 +func estimateInputVideoSeconds(ratios map[string]any) float64 { + if s, ok := ratioFloat(ratios, "spec.input_video_seconds"); ok && s > 0 { + return s + } + if has, _ := ratios["spec.has_input_video"].(bool); has { + return inputVideoEstimateSeconds + } + return 0 +} + +// ratioFloatOr 取计费上下文 float 值,缺失返回默认值 +func ratioFloatOr(ratios map[string]any, key string, def float64) float64 { + if f, ok := ratioFloat(ratios, key); ok { + return f + } + return def +} diff --git a/internal/logic/billing/scheme_minimax_material_test.go b/internal/logic/billing/scheme_minimax_material_test.go new file mode 100644 index 0000000..12b683e --- /dev/null +++ b/internal/logic/billing/scheme_minimax_material_test.go @@ -0,0 +1,169 @@ +package billing + +import ( + "encoding/json" + "testing" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// mmTestPricing 构造 MiniMax 素材方案测试定价: +// 按秒矩阵 768P=0.35 / 2K=0.6(输出生成与输入视频共用);图片 5 张免费、超出 0.20/张。 +// 乘数恒 1(租户/时段折扣单测用例另行覆盖)。 +// billing_mode 用 special(方案模型的标准配对形态);分发只看 Scheme, +// per_second+scheme 的存量组合由 scheme_test 覆盖。 +func mmTestPricing(t *testing.T, tenantMul float64) *PricingResult { + t.Helper() + return &PricingResult{ + BillingMode: BillingModeSpecial, + TenantMultiplier: tenantMul, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6}, + Scheme: SchemeMiniMaxMaterial, + SchemeConfig: json.RawMessage(`{ + "image": {"free_count": 5, "price_per_unit": 0.20} + }`), + } +} + +// TestMinimaxMaterial_Estimate 预扣口径:按秒组件(输出 + 输入视频同价)+ 提交可知的图片组件 +// (图片精确、含输入视频按生成长度上限 15s 冻结)。 +func TestMinimaxMaterial_Estimate(t *testing.T) { + pricing := mmTestPricing(t, 1.0) + + // 纯文生视频:6s × 768P 输出价,无素材费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", + }, nil), 2.1, "t2v output only") + + // 8 张图:输出 2.1 + (8-5)×0.20 = 2.7(5 张内免费见下一例) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, nil), 2.7, "8 images beyond free quota") + + // 4 张图(免费额度内):图费 0 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 4.0, + }, nil), 2.1, "4 images within free quota") + + // 提交不含输入视频(has_input_video 缺省):预扣不含输入视频费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", "spec.input_image_count": 8.0, + }, nil), 6*0.6+3*0.20, "no input video at estimate") + + // 提交含输入视频(时长未知):按生成长度上限 15s × 生成分辨率单价冻结 → 2.1 + 15×0.35 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, + }, nil), 2.1+15*0.35, "input video frozen at generation cap") + + // has_input_video=false 显式无视频:不含输入视频费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": false, + }, nil), 2.1, "explicit no input video") + + // adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限, + // 单价同生成分辨率(2K) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", "spec.has_input_video": true, "spec.input_video_seconds": 8.0, + }, nil), 6*0.6+8*0.6, "input video estimated via ratios") + + // 矩阵未配置(查价为 0):输出与输入视频均不计费,仅图片组件 + noMatrix := mmTestPricing(t, 1.0) + noMatrix.PerSecondPrices = nil + assertDecimal(t, estimateTaskCost(noMatrix, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true, + }, nil), 3*0.20, "empty matrix: no per-second components") +} + +// TestMinimaxMaterial_Settle 结算口径:官方 usage 为最终计费依据。 +func TestMinimaxMaterial_Settle(t *testing.T) { + pricing := mmTestPricing(t, 1.0) + scheme := minimaxMaterialScheme{} + + // usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.35(与输出同价) + cost := scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 8, InputImageCount: 8, OutputSeconds: 6}) + assertDecimal(t, cost, 6*0.35+3*0.20+8*0.35, "settle full usage 768P") + + // 2K 档:输出与输入视频均按生成分辨率单价 → (6+10)×0.6 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 10, OutputSeconds: 6}) + assertDecimal(t, cost, 16*0.6, "settle 2K with input video") + + // usage 字段部分缺失:图片回退 ratios 提交时事实值,输出回退 spec.duration; + // 输入视频缺失且提交不含视频 → 0 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) + assertDecimal(t, cost, 5*0.35+3*0.20, "settle partial usage falls back to ratios") + + // 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限 × 生成分辨率单价) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true, + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) + assertDecimal(t, cost, 5*0.35+3*0.20+15*0.35, "settle missing input seconds falls back to cap estimate") + + // usage 为 nil:整体回退预扣口径(含输入视频时同样含 15s 冻结) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, nil) + assertDecimal(t, cost, 2.7, "settle nil usage falls back to estimate") + + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, + }, nil) + assertDecimal(t, cost, (6+15)*0.35, "settle nil usage with input video falls back to cap estimate") + + // 输入视频异常大值钳制到 600s(防 usage 异常刷扣) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.resolution": "768P", + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 99999, OutputSeconds: 1}) + assertDecimal(t, cost, 1*0.35+600*0.35, "settle clamps input video seconds") +} + +// TestMinimaxMaterial_Multipliers 租户折扣作用于全部组件(输出 + 素材)。 +func TestMinimaxMaterial_Multipliers(t *testing.T) { + pricing := mmTestPricing(t, 0.5) + cost := estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.input_video_seconds": 4.0, + }, nil) + assertDecimal(t, cost, (6*0.35+3*0.20+4*0.35)*0.5, "tenant multiplier applies to all components") +} + +// TestMinimaxMaterial_ValidateSchemeConfig 方案私有配置校验。 +func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) { + scheme := minimaxMaterialScheme{} + + // 合法配置 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":5,"price_per_unit":0.2}}`)); err != nil { + t.Errorf("valid image config rejected: %v", err) + } + // 空 / null / 空对象 → 合法(图片免费,输出与输入视频均按 per_second 矩阵计费) + for _, cfg := range []json.RawMessage{nil, json.RawMessage("null"), json.RawMessage(`{}`)} { + if err := scheme.ValidateSchemeConfig(cfg); err != nil { + t.Errorf("empty config should be accepted: %s (%v)", cfg, err) + } + } + // 负单价 / 负免费额度 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"price_per_unit":-1}}`)); err == nil { + t.Error("negative image price should be rejected") + } + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":-1,"price_per_unit":0.2}}`)); err == nil { + t.Error("negative free count should be rejected") + } + // input_video 节已废弃(改按生成分辨率单价计费):残留即拒绝,防旧配置静默失效 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err == nil { + t.Error("deprecated input_video section should be rejected") + } +} + +// TestMinimaxMaterial_Registered 方案已注册且可经 LookupScheme 分发。 +func TestMinimaxMaterial_Registered(t *testing.T) { + if !SchemeRegistered(SchemeMiniMaxMaterial) { + t.Fatal("minimax material scheme should be registered") + } + if got := LookupScheme(SchemeMiniMaxMaterial).Name(); got != SchemeMiniMaxMaterial { + t.Errorf("lookup returned %s", got) + } +} diff --git a/internal/logic/billing/scheme_per_second_settle_test.go b/internal/logic/billing/scheme_per_second_settle_test.go new file mode 100644 index 0000000..0d65ee9 --- /dev/null +++ b/internal/logic/billing/scheme_per_second_settle_test.go @@ -0,0 +1,104 @@ +package billing + +import ( + "testing" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// generic per_second 按实际输出秒数结算(方案 A)的行为测试。 +// 矩阵:480P=0.02 / 720P=0.05 / 1080P=0.10,"*" 兜底 0.05(对齐 wan3.0 三档定价形态)。 + +// perSecondTestPricing 构造 generic per_second 测试定价(乘数恒 1,租户折扣单测另行覆盖) +func perSecondTestPricing(tenantMul float64) *PricingResult { + return &PricingResult{ + BillingMode: "per_second", + TenantMultiplier: tenantMul, + PerSecondPrices: map[string]float64{"480P": 0.02, "720P": 0.05, "1080P": 0.10, "*": 0.05}, + } +} + +// TestGenericPerSecond_SettleByActualSeconds 结算口径:上游返回实际输出秒数时按 +// 「实际秒数 × 档位单价 × 租户 × 时段」结算,多退少补;usage 缺失回退预扣口径。 +func TestGenericPerSecond_SettleByActualSeconds(t *testing.T) { + pricing := perSecondTestPricing(1.0) + scheme := GenericScheme{} + + // 实际 5.13s(上游浮点秒)× 720P 档:请求 5s 预扣与实际有零头差,按实际结算 + cost := scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"}) + assertDecimal(t, cost, 5.13*0.05, "settle by actual seconds") + + // usage.Resolution 优先于 ratios 提交时档位(请求未指定档位,实际输出 480P) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "480P"}) + assertDecimal(t, cost, 5*0.02, "usage resolution wins over ratios") + + // usage.Resolution 缺失回退 ratios 提交时档位;实际秒数长于请求时补扣 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "1080P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 12}) + assertDecimal(t, cost, 12*0.10, "fallback resolution, supplement longer actual") + + // 智能时长场景:预扣按上限 30s 冻结,实际生成 12s 结算退差 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 30.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 12, Resolution: "720P"}) + assertDecimal(t, cost, 12*0.05, "smart duration refunds difference") + + // 异常大秒数钳制 120s(与预扣钳制同源,防 usage 异常刷扣) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 99999, Resolution: "720P"}) + assertDecimal(t, cost, 120*0.05, "clamp abnormal seconds") + + // 档位未命中矩阵 → "*" 兜底价 + cost = scheme.SettleTaskCost(pricing, map[string]any{}, + &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "360P"}) + assertDecimal(t, cost, 5*0.05, "unknown resolution falls back to wildcard") + + // usage 为 nil:回退预扣口径(请求 5s × 720P) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, nil) + assertDecimal(t, cost, 5*0.05, "nil usage falls back to estimate") + + // usage 存在但 OutputSeconds=0:同样回退预扣口径 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{Resolution: "720P"}) + assertDecimal(t, cost, 5*0.05, "zero output seconds falls back to estimate") + + // 矩阵为空/全零:落到预扣分支的占位兜底(与预扣行为一致) + empty := perSecondTestPricing(1.0) + empty.PerSecondPrices = nil + cost = scheme.SettleTaskCost(empty, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5, Resolution: "720P"}) + assertDecimal(t, cost, imagePlaceholderPreDeduct, "empty matrix falls back to placeholder") +} + +// TestGenericPerSecond_SettleTenantMultiplier 租户折扣作用于实际秒数结算。 +func TestGenericPerSecond_SettleTenantMultiplier(t *testing.T) { + pricing := perSecondTestPricing(0.5) + scheme := GenericScheme{} + + cost := scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 5.0, "spec.resolution": "720P", + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5.13, Resolution: "720P"}) + assertDecimal(t, cost, 5.13*0.05*0.5, "tenant multiplier applies to actual seconds") +} + +// TestGenericPerSecond_NonPerSecondModesIgnoreUsage 回归:非 per_second 模式(token 等) +// 的结算不受 usage 影响,保持「预扣即终价」。 +func TestGenericPerSecond_NonPerSecondModesIgnoreUsage(t *testing.T) { + scheme := GenericScheme{} + + // token 模式 + usage 带秒数:忽略 usage,走预扣口径(无时长信号 → 占位兜底) + tokenPricing := &PricingResult{BillingMode: "token", OutputPrice: 3.0, TenantMultiplier: 1.0} + cost := scheme.SettleTaskCost(tokenPricing, map[string]any{}, + &rcommon.TaskMaterialUsage{OutputSeconds: 100}) + assertDecimal(t, cost, imagePlaceholderPreDeduct, "token mode ignores usage") +} diff --git a/internal/logic/billing/scheme_test.go b/internal/logic/billing/scheme_test.go new file mode 100644 index 0000000..98ae415 --- /dev/null +++ b/internal/logic/billing/scheme_test.go @@ -0,0 +1,142 @@ +package billing + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// testConfigScheme 测试用方案:演示方案实现的正确形态——注册实例无状态单例, +// 费用参数从 pricing.SchemeConfig(不透明容器)读取,同名注册仅一次。 +type testConfigScheme struct{} + +func (testConfigScheme) Name() string { return "test:config-scheme" } + +func (testConfigScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + var c struct { + Base float64 `json:"base"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if c.Base < 0 { + return gerror.New("base 不能为负") + } + return nil +} + +func (testConfigScheme) EstimateTaskCost(pricing *PricingResult, _ map[string]any, _ []byte) decimal.Decimal { + var c struct { + Base float64 `json:"base"` + } + _ = json.Unmarshal(pricing.SchemeConfig, &c) + return NewFromFloat(c.Base) +} + +// SettleTaskCost 测试方案:usage 携带秒数时按秒 × base 计,否则回退预扣口径 +func (testConfigScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal { + if usage != nil && usage.OutputSeconds > 0 { + var c struct { + Base float64 `json:"base"` + } + _ = json.Unmarshal(pricing.SchemeConfig, &c) + return NewFromFloat(c.Base * usage.OutputSeconds) + } + return testConfigScheme{}.EstimateTaskCost(pricing, ratios, nil) +} + +// TestSchemeRegistry 注册表基础行为:generic 默认注册、空名/未注册回落、SchemeRegistered 判定。 +func TestSchemeRegistry(t *testing.T) { + if !SchemeRegistered(SchemeGeneric) { + t.Fatal("generic scheme should be pre-registered") + } + if SchemeRegistered("test:not-registered") { + t.Fatal("unregistered name should report false") + } + + // 空名与未注册名都必须回落 generic(纯函数层防御) + if got := LookupScheme(""); got.Name() != SchemeGeneric { + t.Errorf("empty name should fall back to generic, got %s", got.Name()) + } + if got := LookupScheme("no:such-scheme"); got.Name() != SchemeGeneric { + t.Errorf("unknown name should fall back to generic, got %s", got.Name()) + } +} + +// TestGenericSchemeConfigValidation generic 方案不接受非空 scheme_config +// (拦截「空方案带配置」的错位数据;null 视同未配置)。 +func TestGenericSchemeConfigValidation(t *testing.T) { + g := GenericScheme{} + if err := g.ValidateSchemeConfig(nil); err != nil { + t.Errorf("nil config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage("null")); err != nil { + t.Errorf("null config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage(`{"x":1}`)); err == nil { + t.Error("non-empty config should be rejected for generic scheme") + } +} + +// TestSchemeDispatch 特殊方案分发:pricing.Scheme 命中注册方案后估算走方案实现, +// 且通用层统一应用附加乘数、minCost 钳制(方案实现不得自行处理,全站口径收口)。 +func TestSchemeDispatch(t *testing.T) { + RegisterScheme(testConfigScheme{}) + + pricing := &PricingResult{ + BillingMode: BillingModeSpecial, // 方案模型的标准配对形态;分发只看 Scheme,billing_mode 是参考字段 + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":2.0}`), + } + + // 分发命中:方案读 scheme_config 返回 2.0,无附加乘数时原样(高于 minCost 不触发钳制) + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 2.0, "scheme dispatch base") + + // 通用层应用附加乘数:2.0 × 0.5 = 1.0(方案实现未乘,证明 applyRatioMultipliers 收口在骨架) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"video_input": 0.5}, nil), 1.0, "scheme with ratio multiplier") + + // spec.* 事实键不参与乘法(通用层跳过) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.resolution": "720p"}, nil), 2.0, "spec facts not multiplied") + + // 通用层 minCost 钳制:方案低返回值(0.001)被钳到 0.01 + lowPricing := &PricingResult{ + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":0.001}`), + } + assertDecimal(t, estimateTaskCost(lowPricing, nil, nil), 0.01, "scheme result clamped by minCost") +} + +// TestSchemeDispatchGenericEquivalence 未声明 scheme 的定价走 generic 引擎, +// 结果与 billing_mode 直接计算一致(重构等价性的分发层验证,具体口径见 task_billing_test)。 +func TestSchemeDispatchGenericEquivalence(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.05, + TenantMultiplier: 1.0, + } + if got := LookupScheme(pricing.Scheme).Name(); got != SchemeGeneric { + t.Errorf("empty scheme should resolve to generic, got %s", got) + } + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "generic equivalence per_request") +} + +// TestSchemeRegisteredNameUniqueness 重名注册 panic(注册期快死优于带病运行)。 +func TestSchemeRegisteredNameUniqueness(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Error("duplicate registration should panic") + } else if !strings.Contains(fmt.Sprint(r), "duplicate scheme") { + t.Errorf("panic message should mention duplicate scheme, got %v", r) + } + }() + RegisterScheme(GenericScheme{}) +} diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 0c6b15a..4792bee 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -123,10 +123,11 @@ func executeSettlement(ctx context.Context, p settlementTxParams) (int64, error) _, err = dao.BilTransactions.Ctx(ctx).Data(p.buildTransaction(billingID, InexactFloat64(balanceAfterD), InexactFloat64(frozenAfterD))).Insert() if err != nil { - // 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费) - if _, _, creditErr := CreditWalletRedis(ctx, p.tenantID, actualCostD); creditErr != nil { + // 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费)。 + // 用 ReverseConsumeRedis 而非 CreditWalletRedis:余额与累计消费必须同一原子回退 + if reverseErr := ReverseConsumeRedis(ctx, p.tenantID, actualCostD); reverseErr != nil { g.Log().Errorf(ctx, "%s: compensate reverse charge failed: tenant=%d request=%v cost=%.6f: %v", - p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, creditErr) + p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, reverseErr) } if delErr := deleteBillingRecord(ctx, billingID); delErr != nil { g.Log().Errorf(ctx, "%s: compensate delete billing record %d failed: %v", p.logPrefix, billingID, delErr) @@ -389,7 +390,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID if pricingResult != nil { snapshot := GenerateBillingSnapshot(pricingResult, breakdown, usage, settlementResult, relayInfo) snapshotJSON = SnapshotToJSON(snapshot) - summaryText = GenerateBillingSummary(snapshot) + summaryText = GenerateBillingSummary(ctx, snapshot) settlementResult.BillingMode = pricingResult.BillingMode settlementResult.BillingSource = pricingResult.BillingSource settlementResult.RateMultiplier = pricingResult.DiscountRatio diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 0974d0c..84b4cee 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -1,6 +1,7 @@ package billing import ( + "context" "encoding/json" "fmt" @@ -25,6 +26,12 @@ type BillingSnapshotPricing struct { EffectiveOutputPrice float64 `json:"effective_output_price"` BillingMode string `json:"billing_mode"` BillingSource string `json:"billing_source"` + // Scheme 命中的特殊计费方案名(pricing JSONB 顶层 scheme 键);空 = 通用引擎。 + // 供账单事后追溯该笔走的计费方案,摘要据此追加「计费方案」行 + Scheme string `json:"scheme,omitempty"` + // PerSecondPrices 按秒单价矩阵(仅 per_second / special 模式填充,其余模式 omitted): + // special 的矩阵是输出生成组件的定价依据,快照携带供账单解释 + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` } // BillingSnapshotMultipliers 倍率信息 @@ -87,6 +94,8 @@ func GenerateBillingSnapshot( EffectiveOutputPrice: pricing.OutputPrice, BillingMode: pricing.BillingMode, BillingSource: pricing.BillingSource, + Scheme: pricing.Scheme, + PerSecondPrices: pricing.PerSecondPrices, }, Multipliers: BillingSnapshotMultipliers{ ModelMultiplier: pricing.ModelMultiplier, @@ -172,12 +181,17 @@ func buildTokenCosts(pricing *PricingResult, breakdown *CostBreakdown) map[strin return costs } -// GenerateBillingSummary 生成人类可读的计费摘要文本(中文) -func GenerateBillingSummary(snapshot *BillingSnapshot) string { +// GenerateBillingSummary 生成人类可读的计费摘要文本(中文)。 +// 金额符号取系统本位币(billing_currency),摘要为写入时快照,货币在初始化后不可更改。 +func GenerateBillingSummary(ctx context.Context, snapshot *BillingSnapshot) string { if snapshot == nil { return "" } + // 货币符号跟随本位币;money 统一拼接符号 + 价格 + sym := CurrencySymbol(ctx) + money := func(v float64) string { return sym + formatPrice(v) } + // 价格来源中文映射 sourceMap := map[string]string{ "base": "基础定价", @@ -191,9 +205,11 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string { // 计费模式中文映射 modeMap := map[string]string{ - "token": "按量计费", - "per_request": "按次计费", - "tiered": "阶梯计费", + "token": "按量计费", + "per_request": "按次计费", + "tiered": "阶梯计费", + "per_second": "按秒计费", + BillingModeSpecial: "特殊计费", } mode := modeMap[snapshot.Pricing.BillingMode] if mode == "" { @@ -208,28 +224,33 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string { lines := make([]string, 0, 15) lines = append(lines, fmt.Sprintf("模型: %s | 计费模式: %s | 价格来源: %s", modelName, mode, source)) + // 特殊计费方案行:方案名直出(custom:minimax-material 等),便于账单事后追溯; + // 不做后端中文映射表——新增方案不应要求改动本文件(与 scheme 注册表设计一致) + if snapshot.Pricing.Scheme != "" { + lines = append(lines, fmt.Sprintf("计费方案: %s", snapshot.Pricing.Scheme)) + } lines = append(lines, "---") // 按次计费特殊处理 if snapshot.Pricing.BillingMode == "per_request" { - lines = append(lines, fmt.Sprintf("按次单价: $%.6f", snapshot.Pricing.EffectiveInputPrice)) + lines = append(lines, fmt.Sprintf("按次单价: %s%.6f", sym, snapshot.Pricing.EffectiveInputPrice)) } else { // 各类 token 费用明细 if tc, ok := snapshot.TokenCosts["input"]; ok && tc.Tokens > 0 { - lines = append(lines, fmt.Sprintf("输入: %s tokens × $%s/1M = $%s", - formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost))) + lines = append(lines, fmt.Sprintf("输入: %s tokens × %s/1M = %s", + formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost))) } if tc, ok := snapshot.TokenCosts["output"]; ok && tc.Tokens > 0 { - lines = append(lines, fmt.Sprintf("输出: %s tokens × $%s/1M = $%s", - formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost))) + lines = append(lines, fmt.Sprintf("输出: %s tokens × %s/1M = %s", + formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost))) } if tc, ok := snapshot.TokenCosts["cache_read"]; ok && tc.Tokens > 0 { - lines = append(lines, fmt.Sprintf("缓存读取: %s tokens × $%s/1M = $%s", - formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost))) + lines = append(lines, fmt.Sprintf("缓存读取: %s tokens × %s/1M = %s", + formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost))) } if tc, ok := snapshot.TokenCosts["cache_creation"]; ok && tc.Tokens > 0 { - lines = append(lines, fmt.Sprintf("缓存创建: %s tokens × $%s/1M = $%s", - formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost))) + lines = append(lines, fmt.Sprintf("缓存创建: %s tokens × %s/1M = %s", + formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost))) } // 小计 × 倍率:展开各项费用明细,乘法链 = 租户倍率 × 时段乘数 @@ -242,16 +263,16 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string { // 收集各项费用明细 var costParts []string if tc, ok := snapshot.TokenCosts["input"]; ok && tc.Cost > 0 { - costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost))) + costParts = append(costParts, money(tc.Cost)) } if tc, ok := snapshot.TokenCosts["output"]; ok && tc.Cost > 0 { - costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost))) + costParts = append(costParts, money(tc.Cost)) } if tc, ok := snapshot.TokenCosts["cache_read"]; ok && tc.Cost > 0 { - costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost))) + costParts = append(costParts, money(tc.Cost)) } if tc, ok := snapshot.TokenCosts["cache_creation"]; ok && tc.Cost > 0 { - costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost))) + costParts = append(costParts, money(tc.Cost)) } // 构建倍率描述(租户倍率和时段乘数是并列关系,带文字标签) @@ -260,17 +281,19 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string { 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 != "" { - lines = append(lines, fmt.Sprintf("小计: %s × %s = $%s", - costsExpr, multDesc, formatPrice(snapshot.Settlement.ActualCost))) + lines = append(lines, fmt.Sprintf("小计: %s × %s = %s", + costsExpr, multDesc, money(snapshot.Settlement.ActualCost))) } } } @@ -285,17 +308,17 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string { s := snapshot.Settlement if s.PreDeductAmount > 0 { if s.RefundAmount > 0 { - lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s → 退还: $%s", - formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost), formatPrice(s.RefundAmount))) + lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s → 退还: %s", + money(s.PreDeductAmount), money(s.ActualCost), money(s.RefundAmount))) } else if s.SupplementAmount > 0 { - lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s → 补扣: $%s", - formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost), formatPrice(s.SupplementAmount))) + lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s → 补扣: %s", + money(s.PreDeductAmount), money(s.ActualCost), money(s.SupplementAmount))) } else { - lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s(无差额)", - formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost))) + lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s(无差额)", + money(s.PreDeductAmount), money(s.ActualCost))) } } else if s.ActualCost > 0 { - lines = append(lines, fmt.Sprintf("实际费用: $%s", formatPrice(s.ActualCost))) + lines = append(lines, fmt.Sprintf("实际费用: %s", money(s.ActualCost))) } result := "" diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index 7e6640e..57b412a 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -1,6 +1,7 @@ package billing import ( + "context" "encoding/json" "strings" "testing" @@ -268,9 +269,93 @@ func TestGenerateBillingSnapshot_WithRelayInfo(t *testing.T) { } func TestGenerateBillingSummary_Nil(t *testing.T) { - got := GenerateBillingSummary(nil) + got := GenerateBillingSummary(context.Background(), nil) if got != "" { - t.Errorf("GenerateBillingSummary(nil) = %q, want empty", got) + t.Errorf("GenerateBillingSummary(context.Background(), nil) = %q, want empty", got) + } +} + +// TestGenerateBillingSnapshot_SchemeField 特殊计费方案:scheme + 按秒矩阵落快照 Pricing 节, +// 供账单事后追溯该笔走的计费方案与输出单价依据 +func TestGenerateBillingSnapshot_SchemeField(t *testing.T) { + pricing := &PricingResult{ + BillingMode: BillingModeSpecial, + BillingSource: "base", + TenantMultiplier: 1.0, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6}, + Scheme: SchemeMiniMaxMaterial, + } + snapshot := GenerateBillingSnapshot(pricing, &CostBreakdown{TotalCost: 2.1}, nil, nil, nil) + if snapshot == nil { + t.Fatal("expected non-nil snapshot") + } + if snapshot.Pricing.Scheme != SchemeMiniMaxMaterial { + t.Errorf("Pricing.Scheme = %q, want %q", snapshot.Pricing.Scheme, SchemeMiniMaxMaterial) + } + if snapshot.Pricing.BillingMode != BillingModeSpecial { + t.Errorf("Pricing.BillingMode = %q, want %q", snapshot.Pricing.BillingMode, BillingModeSpecial) + } + if snapshot.Pricing.PerSecondPrices["768P"] != 0.35 { + t.Errorf("Pricing.PerSecondPrices lost: %+v", snapshot.Pricing.PerSecondPrices) + } + + // JSON 往返保留(bil_usage_logs.billing_snapshot 的真实存取路径) + var parsed BillingSnapshot + if err := json.Unmarshal([]byte(SnapshotToJSON(snapshot)), &parsed); err != nil { + t.Fatalf("roundtrip unmarshal: %v", err) + } + if parsed.Pricing.Scheme != SchemeMiniMaxMaterial { + t.Errorf("roundtrip Scheme lost: %q", parsed.Pricing.Scheme) + } + if parsed.Pricing.PerSecondPrices["2K"] != 0.6 { + t.Errorf("roundtrip PerSecondPrices lost: %+v", parsed.Pricing.PerSecondPrices) + } + + // token 模式快照不带矩阵(omitempty,旧结构零变化) + tokenSnapshot := GenerateBillingSnapshot(&PricingResult{ + BillingMode: "token", InputPrice: 0.5, OutputPrice: 1.5, TenantMultiplier: 1.0, + }, &CostBreakdown{}, nil, nil, nil) + if tokenSnapshot.Pricing.PerSecondPrices != nil { + t.Errorf("token snapshot should not carry per_second_prices: %+v", tokenSnapshot.Pricing.PerSecondPrices) + } +} + +// TestGenerateBillingSummary_SchemeAndPerSecond 摘要模式中文映射补全: +// special 显示「特殊计费」并追加「计费方案」行;per_second 显示「按秒计费」 +// (此前两值均直出英文原始串) +func TestGenerateBillingSummary_SchemeAndPerSecond(t *testing.T) { + // special + 方案:含「特殊计费」标签与「计费方案:」行 + special := &BillingSnapshot{ + Pricing: BillingSnapshotPricing{ + BillingMode: BillingModeSpecial, + BillingSource: "base", + Scheme: SchemeMiniMaxMaterial, + }, + Multipliers: BillingSnapshotMultipliers{TenantMultiplier: 1.0}, + Settlement: BillingSnapshotSettlement{PreDeductAmount: 2.1, ActualCost: 2.1}, + RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "MiniMax-H3"}, + } + text := GenerateBillingSummary(context.Background(), special) + if !strings.Contains(text, "特殊计费") { + t.Error("summary should contain 特殊计费") + } + if !strings.Contains(text, "计费方案: "+SchemeMiniMaxMaterial) { + t.Error("summary should contain scheme line") + } + + // per_second:中文标签不再直出原始串 + perSecond := &BillingSnapshot{ + Pricing: BillingSnapshotPricing{ + BillingMode: "per_second", + BillingSource: "base", + }, + Multipliers: BillingSnapshotMultipliers{TenantMultiplier: 1.0}, + Settlement: BillingSnapshotSettlement{PreDeductAmount: 4.0, ActualCost: 4.0}, + RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "kling-v2"}, + } + text = GenerateBillingSummary(context.Background(), perSecond) + if !strings.Contains(text, "按秒计费") { + t.Error("summary should contain 按秒计费") } } @@ -293,7 +378,7 @@ func TestGenerateBillingSummary_PerRequest(t *testing.T) { }, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) if !strings.Contains(text, "按次计费") { t.Error("summary should contain 按次计费") } @@ -330,7 +415,7 @@ func TestGenerateBillingSummary_TokenMode(t *testing.T) { }, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) if !strings.Contains(text, "按量计费") { t.Error("summary should contain 按量计费") } @@ -371,7 +456,7 @@ func TestGenerateBillingSummary_SupplementAmount(t *testing.T) { }, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) if !strings.Contains(text, "补扣") { t.Error("summary should contain 补扣") } @@ -399,7 +484,7 @@ func TestGenerateBillingSummary_NoDiff(t *testing.T) { }, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) if !strings.Contains(text, "无差额") { t.Error("summary should contain 无差额") } @@ -416,7 +501,7 @@ func TestGenerateBillingSummary_UnknownSourcePassthrough(t *testing.T) { RequestMeta: BillingSnapshotRequestMeta{}, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) if !strings.Contains(text, "custom_source") { t.Error("unknown source should be passed through as-is") } @@ -486,11 +571,11 @@ func TestGenerateBillingSummary_TimeRule(t *testing.T) { RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "gpt-4o"}, } - text := GenerateBillingSummary(snapshot) + text := GenerateBillingSummary(context.Background(), snapshot) 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/billing/sweeper.go b/internal/logic/billing/sweeper.go index 5708fb9..d3e0952 100644 --- a/internal/logic/billing/sweeper.go +++ b/internal/logic/billing/sweeper.go @@ -80,7 +80,7 @@ func materializeDirtyWallets(ctx context.Context) { // materializeOneWallet 物化单个租户钱包;成功或 hash 缺失时清除脏标记 func materializeOneWallet(ctx context.Context, tenantID int64) { walletRedisKey := walletHashKey(tenantID) - balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID) + balanceMicro, frozenMicro, consumedMicro, hasConsumed, exists, err := readWalletHash(ctx, tenantID) if err != nil { // Redis 故障:保留脏标记,下 tick 重试 g.Log().Warningf(ctx, "[WALLET MATERIALIZER] read wallet hash failed: tenant=%d: %v", tenantID, err) @@ -92,10 +92,27 @@ func materializeOneWallet(ctx context.Context, tenantID int64) { return } - // decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen 全量覆盖 + // 自愈兜底(三层补种之③层):hash 存在但缺 total_consumed 字段(boot 种子后新出现的 + // 字段缺失 hash,如灾后经充值路径重建),从 DB 物化副本补种历史基线——否则下方全量 + // 覆盖会把回填好的历史值写回 0 + if !hasConsumed { + dbConsumed, qErr := queryTotalConsumed(ctx, tenantID) + if qErr != nil { + // DB 故障:保留脏标记,下 tick 重试 + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] query total_consumed for seed failed: tenant=%d: %v", tenantID, qErr) + return + } + if sErr := seedTotalConsumedIfMissing(ctx, tenantID, ToMicro(dbConsumed)); sErr != nil { + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] seed total_consumed failed: tenant=%d: %v", tenantID, sErr) + return + } + consumedMicro = ToMicro(dbConsumed) + } + + // decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen/total_consumed 全量覆盖 _, err = g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET balance = ?, frozen_balance = ?, updated_at = NOW() WHERE tenant_id = ?", - FromMicro(balanceMicro), FromMicro(frozenMicro), tenantID) + "UPDATE bil_wallets SET balance = ?, frozen_balance = ?, total_consumed = ?, updated_at = NOW() WHERE tenant_id = ?", + FromMicro(balanceMicro), FromMicro(frozenMicro), FromMicro(consumedMicro), tenantID) if err != nil { // DB 故障:保留脏标记,下 tick 重试 g.Log().Warningf(ctx, "[WALLET MATERIALIZER] update db wallet failed: tenant=%d: %v", tenantID, err) diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 02534df..edddb42 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "strings" "time" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/shopspring/decimal" @@ -24,13 +26,32 @@ func NewTaskBillingProvider() common.TaskBillingProvider { } // EstimateTaskCost 估算任务费用 -// ratios 包含计费比率(如 video_input 折扣)和预估参数(如 duration 秒数、resolution 乘数) -func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error) { +// ratios 包含计费上下文(float64 乘数如 video_input 折扣 + spec.* 规格事实值,见 TaskAdaptor.EstimateBilling); +// taskBody 为归一化任务体,参数倍率(param_multipliers)命中时就地注入 ratios 随任务持久化 +func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error) { pricing, err := GetModelPrice(ctx, tenantID, modelName) if err != nil { return NewFromFloat(0.01), nil } - return estimateTaskCost(pricing, ratios), nil + // 特殊计费方案 fail-closed:定价引用了未注册的方案(方案代码缺失/被回滚)时 + // 引擎无从得知正确价格,静默回落 generic 会错价,拒绝任务提交与「未配价模型 + // 不得放行」的同步路径口径一致 + if pricing.Scheme != "" && !SchemeRegistered(pricing.Scheme) { + return Zero, gerror.Newf("billing scheme %q not registered (model=%s)", pricing.Scheme, modelName) + } + // special 必须与方案配对(写侧已双向校验,此处兜底防手工改库):裸 special 会落 + // generic 分发走 default 分支,计费口径不可预期,拒绝任务提交 + if pricing.BillingMode == BillingModeSpecial && pricing.Scheme == "" { + return Zero, gerror.Newf("billing_mode special requires scheme (model=%s)", modelName) + } + // 参数倍率求值:命中注入 ratios(预扣、per_second 结算、token 重算经 applyRatioMultipliers 自动同口径) + if len(pricing.ParamMultipliers) > 0 && len(taskBody) > 0 && ratios != nil { + if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, taskBody); len(matched) > 0 && m > 0 { + ratios[ratioKeyParamMultiplier] = m + ratios[ratioKeyParamMatched] = strings.Join(matched, "|") + } + } + return estimateTaskCost(pricing, ratios, taskBody), nil } // imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。 @@ -38,77 +59,72 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID // 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。 const imagePlaceholderPreDeduct = 0.1 -// estimateTaskCost 纯函数:根据定价与计费比率估算任务预扣费用,不依赖数据库/缓存,便于单测。 -// -// 计费口径按任务类型分流: -// - per_request(按次计费,图片/音乐等):直接取按次单价; -// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 -// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价; -// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价, -// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出 -// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。 -func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) decimal.Decimal { +// estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。 +// 骨架只做两件事:按 pricing.Scheme 分发到计费方案实现(未声明走 generic 通用引擎), +// 再统一应用附加比率与 minCost 钳制——方案实现只负责组件费用(含租户/时段乘数), +// 附加乘数、钳制、舍入全站收口于此,保证各方案结算口径一致。 +func estimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal { if pricing == nil { return NewFromFloat(0.01) } - var costD decimal.Decimal - switch { - case pricing.BillingMode == "per_request": - // 按次计费:直接用单价 - costD = NewFromFloat(pricing.PerRequestPrice) - case pricing.OutputPrice > 0 && hasDurationSignal(ratios): - duration := 5.0 // 默认 5 秒 - if d, ok := ratios["duration"]; ok && d > 0 { - duration = d - } - resolutionMul := 2.25 // 默认 720p - if r, ok := ratios["resolution"]; ok && r > 0 { - resolutionMul = r - } - - // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier - // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 - // 修复视频预扣三连乘:全程 decimal 避免链式误差 - baseTokensPerSec := decimal.NewFromInt(10000) - durationD := NewFromFloat(duration) - resolutionMulD := NewFromFloat(resolutionMul) - million := decimal.NewFromInt(1_000_000) - - costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). - Div(million). - Mul(NewFromFloat(pricing.OutputPrice)). - Mul(NewFromFloat(pricing.TenantMultiplier)) - default: - // 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣, - // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 - // (见 sync_image_worker.settleSyncImageSuccess)。 - if pricing.PerRequestPrice > 0 { - costD = NewFromFloat(pricing.PerRequestPrice) - } else { - costD = NewFromFloat(imagePlaceholderPreDeduct) - } - } - - // 应用附加比率(video_input 折扣等) - for k, ratio := range ratios { - if k == "duration" || k == "resolution" { - continue - } - costD = costD.Mul(NewFromFloat(ratio)) - } - - minCost := NewFromFloat(0.01) - if costD.LessThan(minCost) { - costD = minCost - } - return RoundMoney(costD) + costD := LookupScheme(pricing.Scheme).EstimateTaskCost(pricing, ratios, taskBody) + return wrapSchemeCost(pricing, ratios, costD) } -// hasDurationSignal 判断计费比率里是否携带时长类任务(视频)的 duration/resolution 信号。 +// ratioFloat 取计费上下文中的 float 值(JSONB 往返后数字均为 float64) +func ratioFloat(ratios map[string]any, key string) (float64, bool) { + v, ok := ratios[key] + if !ok { + return 0, false + } + f, ok := v.(float64) + return f, ok +} + +// ratioString 取计费上下文中的 string 值(spec.* 规格原值) +func ratioString(ratios map[string]any, key string) (string, bool) { + v, ok := ratios[key] + if !ok { + return "", false + } + s, ok := v.(string) + return s, ok +} + +// applyRatioMultipliers 把计费上下文中的 float 乘数按序连乘到 cost 上。 +// spec.*(规格事实值)与 string 值自动跳过;skip 显式追加需跳过的键。 +func applyRatioMultipliers(cost decimal.Decimal, ratios map[string]any, skip ...string) decimal.Decimal { + if len(ratios) == 0 { + return cost + } + for k, v := range ratios { + if strings.HasPrefix(k, "spec.") { + continue + } + f, ok := v.(float64) + if !ok || f <= 0 { + continue + } + skipped := false + for _, s := range skip { + if k == s { + skipped = true + break + } + } + if skipped { + continue + } + cost = cost.Mul(NewFromFloat(f)) + } + return cost +} + +// hasDurationSignal 判断计费上下文里是否携带时长类任务(视频)的 duration/resolution 信号。 // 视频提交管线(VolcengineVideoAdaptor.EstimateBilling)必然写入这两个键;图片等同步/异步 // 任务传 nil ratios,据此区分「该走视频 token 估算」还是「按次计费」。 -func hasDurationSignal(ratios map[string]float64) bool { +func hasDurationSignal(ratios map[string]any) bool { if ratios == nil { return false } @@ -152,9 +168,9 @@ func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID // SettleTaskSuccess 任务成功结算(含计费快照) // totalTokens/completionTokens: 上游返回的 token 用量 -// ratios: 提交时保存的计费比率(如 video_input 折扣) +// ratios: 提交时保存的计费上下文(如 video_input 折扣) // billAt: 任务受理时刻(时段定价按该时刻评估;零值按当前时刻兜底) -func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*common.SettlementResult, error) { +func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*common.SettlementResult, error) { diff := SubtractMoney(actualCost, preDeductAmount) // 1. 获取钱包 @@ -301,7 +317,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, internalSettlement, nil) snapshot.RequestMeta.RequestedModel = modelName result.BillingSnapshot = SnapshotToJSON(snapshot) - result.BillingSummary = GenerateBillingSummary(snapshot) + result.BillingSummary = GenerateBillingSummary(ctx, snapshot) result.BillingMode = pricing.BillingMode result.BillingSource = pricing.BillingSource result.RateMultiplier = pricing.DiscountRatio @@ -334,6 +350,16 @@ func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualC return bd } + // per_second / special(特殊方案)模式:无 token 语义,费用整体记 BaseCost(折扣前成本), + // 与 per_request 快照口径一致。命中档的每秒价在预扣时已消费,此处不重复记录 + // (CostBreakdown 无单价字段先例);special 若漏此分支会落 token 路径,生成 + // 「N tokens × $0」的自相矛盾快照行 + if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial { + bd.BaseCost = preMultiplierCost(actualCost, pricing) + bd.TotalCost = actualCost + return bd + } + // token 模式但没有真实 token 用量(图片等扁平计费任务):把费用整体记为 BaseCost, // 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行 // (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。 @@ -400,11 +426,34 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI return newEstimatedCost, nil } +// RecalculateByMaterials 按上游 usage 的素材计量重算任务费用(素材计费方案的结算依据)。 +// 模型未配置素材方案 / 取价失败 / usage 为 nil 时 ok=false,调用方保持既有结算来源。 +// billAt 为任务受理时刻(时段定价按该时刻评估,与预扣口径一致)。 +func (b *TaskBillingProviderImpl) RecalculateByMaterials(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, usage *common.TaskMaterialUsage, billAt time.Time) (decimal.Decimal, bool, error) { + if usage == nil { + return Zero, false, nil + } + pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt) + if err != nil || pricing == nil { + // 取价失败不阻断结算(调用方回落预扣金额),仅返回 ok=false + return Zero, false, err + } + // 仅显式配置了计费方案、或 generic per_second(消费上游实际输出秒数结算)的模型适用: + // 其余 generic 任务保持「预扣即终价」不被重估(素材计费语义由方案声明,未声明素材的 + // 模型不应因 usage 附带字段改变结算来源)。per_second 放行是安全的:存量按秒渠道 + // (kling/volcengine/sora)适配器均不产出 MaterialUsage,结算链路到不了这里,行为不变 + if pricing.Scheme == "" && pricing.BillingMode != "per_second" { + return Zero, false, nil + } + costD := LookupScheme(pricing.Scheme).SettleTaskCost(pricing, ratios, usage) + return wrapSchemeCost(pricing, ratios, costD), true, nil +} + // RecalculateByTokens 根据上游返回的 total_tokens 重算费用。 // 公式:totalTokens / 1M × output_price × tenant_multiplier × 时段乘数 × 附加比率 // billAt 为任务受理时刻(时段定价按该时刻评估);如果模型没有配置 token 单价(纯按次计费), // 返回 0 表示不做 token 重算。 -func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error) { +func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error) { if totalTokens <= 0 { return Zero, nil } @@ -414,6 +463,13 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan return Zero, nil } + // per_second / special(特殊方案)模式按秒计价,token 数与费用无关:返回 0 让结算 + // 保持预扣金额,防止上游附带返回 token 用量时把按秒任务重算成 token 价 + // (special 若漏此分支,租户 custom_output_price 覆盖 + 上游返回 token 时会被错误重算) + if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial { + return Zero, nil + } + // 需要 output_price > 0 才能做 token 重算 if pricing.OutputPrice <= 0 { return Zero, nil @@ -427,13 +483,8 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) // 应用附加比率(视频输入折扣等) - // 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘 - for k, ratio := range ratios { - if k == "duration" || k == "resolution" { - continue - } - costD = costD.Mul(NewFromFloat(ratio)) - } + // 注意:跳过 duration/resolution 与 spec.*——时长/规格已体现在上游返回的 token 数中,不应再乘 + costD = applyRatioMultipliers(costD, ratios, "duration", "resolution") costD = RoundMoney(costD) diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index ca2724c..cdf3dfa 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -165,7 +165,39 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) { OutputPrice: 30.0, // 即使配了 output_price 也不应走 token 估算 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request cost") +} + +// TestEstimateTaskCost_PerRequestTenantTimeDiscount 按次任务预扣即终价(结算无 token 重算 +// 信号),租户乘数与时段乘数必须在预扣阶段就生效,与 chat 路径 computeCost 的按次口径一致。 +func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + // 0.10 × 0.5 × 0.8 = 0.04 + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request tenant × time discount") + + // token 模式但仅配按次价的图片任务(default 分支回退)同样生效 + p2 := &PricingResult{ + BillingMode: "token", + PerRequestPrice: 0.10, + OutputPrice: 30.0, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + assertDecimal(t, estimateTaskCost(p2, nil, nil), 0.04, "image per-request fallback discount") + + // 时段乘数零值(旧缓存条目缺字段)兜底 1.0,不得把预扣清零 + p3 := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0, + } + assertDecimal(t, estimateTaskCost(p3, nil, nil), 0.05, "zero time multiplier fallback") } // TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution) @@ -176,8 +208,8 @@ func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) { OutputPrice: 30.0, TenantMultiplier: 1.0, } - ratios := map[string]float64{"duration": 5, "resolution": 2.25} - assertDecimal(t, estimateTaskCost(pricing, ratios), 3.375, "video cost") + ratios := map[string]any{"duration": 5.0, "resolution": 2.25} + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 3.375, "video cost") } // TestEstimateTaskCost_ImageNoDurationSignal 图片任务(token 模式、有 output_price、但 ratios 无 @@ -190,7 +222,7 @@ func TestEstimateTaskCost_ImageNoDurationSignal(t *testing.T) { PerRequestPrice: 0, // 未配按次价 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.1, "image placeholder pre-deduct") } // TestEstimateTaskCost_PerRequestBelowPlaceholder 已配置的真实按次单价(低于占位值)必须被尊重, @@ -201,7 +233,7 @@ func TestEstimateTaskCost_PerRequestBelowPlaceholder(t *testing.T) { PerRequestPrice: 0.04, // DALL·E 类真实单价,低于 0.1 占位 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "configured per_request price respected") } // TestEstimateTaskCost_ImageUsesPerRequestPrice 图片模型即便 billing_mode 仍是 token,只要配了 @@ -213,7 +245,7 @@ func TestEstimateTaskCost_ImageUsesPerRequestPrice(t *testing.T) { PerRequestPrice: 0.05, TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "image per_request cost") } // TestEstimateTaskCost_VideoInputDiscount 附加比率(video_input 折扣)在时长估算之上叠加。 @@ -223,14 +255,14 @@ func TestEstimateTaskCost_VideoInputDiscount(t *testing.T) { OutputPrice: 30.0, TenantMultiplier: 1.0, } - ratios := map[string]float64{"duration": 5, "resolution": 2.25, "video_input": 0.5} + ratios := map[string]any{"duration": 5.0, "resolution": 2.25, "video_input": 0.5} // 3.375 × 0.5 = 1.6875 - assertDecimal(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost") + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 1.6875, "video discounted cost") } // TestEstimateTaskCost_NilPricing 定价缺失时按最低消费兜底。 func TestEstimateTaskCost_NilPricing(t *testing.T) { - assertDecimal(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor") + assertDecimal(t, estimateTaskCost(nil, nil, nil), 0.01, "nil pricing floor") } // TestHasDurationSignal 区分视频(有 duration/resolution)与图片(nil)。 @@ -238,13 +270,97 @@ func TestHasDurationSignal(t *testing.T) { if hasDurationSignal(nil) { t.Error("nil ratios should not signal duration") } - if hasDurationSignal(map[string]float64{"video_input": 0.5}) { + if hasDurationSignal(map[string]any{"video_input": 0.5}) { t.Error("video_input alone should not signal duration") } - if !hasDurationSignal(map[string]float64{"duration": 5}) { + if !hasDurationSignal(map[string]any{"duration": 5.0}) { t.Error("duration key should signal") } - if !hasDurationSignal(map[string]float64{"resolution": 2.25}) { + if !hasDurationSignal(map[string]any{"resolution": 2.25}) { t.Error("resolution key should signal") } } + +// TestEstimateTaskCost_PerSecond 按秒计费:矩阵查价 × 秒数 × 租户乘数。 +// spec.resolution 命中矩阵档位;未命中回退 "*";无 "*" 回退矩阵最低价。 +func TestEstimateTaskCost_PerSecond(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_second", + TenantMultiplier: 1.0, + PerSecondPrices: map[string]float64{"480p": 0.25, "720p": 0.5, "1080p": 1.0, "*": 0.5}, + } + + // 720p × 8s × 1.0 = 4.0 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 8.0, "spec.resolution": "720p", + }, nil), 4.0, "per_second 720p") + + // 未配规格(2k)→ "*" 兜底 0.5 × 8s = 4.0 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 8.0, "spec.resolution": "2k", + }, nil), 4.0, "per_second wildcard fallback") + + // 无时长信号 → 缺省 5s × "*" 0.5 = 2.5 + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 2.5, "per_second default duration") + + // 租户乘数与附加折扣叠加:1080p × 10s × 0.8(租户) × 0.6(video_input) = 4.8 + p2 := &PricingResult{ + BillingMode: "per_second", + TenantMultiplier: 0.8, + PerSecondPrices: map[string]float64{"1080p": 1.0, "*": 1.0}, + } + assertDecimal(t, estimateTaskCost(p2, map[string]any{ + "spec.duration": 10.0, "spec.resolution": "1080p", "video_input": 0.6, + }, nil), 4.8, "per_second with multipliers") +} + +// TestEstimateTaskCost_PerSecondDurationClamp 天价时长钳制到上限,防 metadata 伪造刷预扣漏洞。 +func TestEstimateTaskCost_PerSecondDurationClamp(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_second", + TenantMultiplier: 1.0, + PerSecondPrices: map[string]float64{"*": 0.5}, + } + // 999999s 钳到 120s × 0.5 = 60 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 999999.0, + }, nil), 60.0, "per_second duration clamp") +} + +// TestEstimateTaskCost_PerSecondEmptyMatrix 矩阵为空/全零:占位预扣 $0.1 兜底(结算多退少补)。 +func TestEstimateTaskCost_PerSecondEmptyMatrix(t *testing.T) { + pricing := &PricingResult{BillingMode: "per_second", TenantMultiplier: 1.0} + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.duration": 8.0}, nil), 0.1, "per_second empty matrix placeholder") +} + +// TestLookupPerSecondPrice 矩阵查价回退链:命中档 → "*" → 矩阵最低价 → 0。 +func TestLookupPerSecondPrice(t *testing.T) { + if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.3}, "720p"); p != 0.5 { + t.Errorf("exact hit = %v", p) + } + if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.3}, "4k"); p != 0.3 { + t.Errorf("wildcard = %v", p) + } + // 无 "*":回退最低价 + if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "1080p": 1.0}, "4k"); p != 0.5 { + t.Errorf("min fallback = %v", p) + } + if p := LookupPerSecondPrice(nil, "720p"); p != 0 { + t.Errorf("empty matrix = %v", p) + } + // 零价档位不参与回退 + if p := LookupPerSecondPrice(map[string]float64{"720p": 0}, "720p"); p != 0 { + t.Errorf("zero price entry = %v", p) + } + // 大小写不敏感兜底:同档不同形态(配置小写/上报大写,或反之)优先于 "*" 通配回退 + if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.9}, "720P"); p != 0.5 { + t.Errorf("case-insensitive hit = %v", p) + } + if p := LookupPerSecondPrice(map[string]float64{"768P": 0.35}, "768p"); p != 0.35 { + t.Errorf("case-insensitive hit (uppercase key) = %v", p) + } + // 大小写不敏感未命中("2k")仍回退 "*" + if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "1080p": 1.0, "*": 0.9}, "2K"); p != 0.9 { + t.Errorf("wildcard after case-insensitive miss = %v", p) + } +} diff --git a/internal/logic/billing/time_pricing.go b/internal/logic/billing/time_pricing.go index 6572df3..46890c8 100644 --- a/internal/logic/billing/time_pricing.go +++ b/internal/logic/billing/time_pricing.go @@ -14,7 +14,7 @@ import ( lcommon "github.com/qianfree/team-api/internal/logic/common" ) -// TimeSegment 时段定价规则(mdl_pricing.time_segments JSONB 数组元素)。 +// TimeSegment 时段定价规则(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。 // 语义:按数组顺序先命中先生效,可用来表达「促销时段压在常驻时段之上」; // 未命中任何时段 → 乘数 1.0(默认价)。最终费用 = 各项小计 × 租户乘数 × 时段乘数。 type TimeSegment struct { diff --git a/internal/logic/billing/transaction.go b/internal/logic/billing/transaction.go index 73f2b38..0b8b360 100644 --- a/internal/logic/billing/transaction.go +++ b/internal/logic/billing/transaction.go @@ -36,7 +36,11 @@ func BuildTransactionQuery(ctx context.Context, params TransactionQueryParams) * query = query.Where("bil_transactions.created_at <= ?", params.EndDate+" 23:59:59") } if params.Username != "" { - query = query.Where("tu.username LIKE ?", "%"+params.Username+"%") + // 用 EXISTS 子查询而非引用调用方 JOIN 的别名(tu.username): + // ① 筛选条件因此不依赖任何 JOIN,调用方的 COUNT / 聚合路径可以完全不联表; + // ② 避免把 tnt_users 拖成驱动表(EXISTS 内是主键等值探测,代价低)。 + // 语义与 LEFT JOIN 后过滤一致:user_id 无对应用户的行同样被过滤掉。 + query = query.Where("EXISTS (SELECT 1 FROM tnt_users ut WHERE ut.id = bil_transactions.user_id AND ut.tenant_id = bil_transactions.tenant_id AND ut.username LIKE ?)", "%"+params.Username+"%") } if params.ModelName != "" { query = query.Where("bil_transactions.model_name LIKE ?", "%"+params.ModelName+"%") diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index c219282..58e9b96 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -31,7 +31,7 @@ const ( ) // walletHashKey 钱包 Redis hash key。 -// v2:balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 +// v2:balance / frozen_balance / total_consumed 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 // // 钱包 hash 是资金状态的【唯一实时权威】(Redis 权威化架构): // - 所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)全部通过 Lua 原子作用于它; @@ -56,6 +56,7 @@ type WalletInfo struct { TenantID int64 Balance float64 FrozenBalance float64 + TotalConsumed float64 WarningThreshold float64 Currency string LowBalanceNotified bool @@ -101,7 +102,7 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { return nil, err } - balance, frozen, err := getWalletRedisState(ctx, tenantID) + balance, frozen, totalConsumed, err := getWalletRedisState(ctx, tenantID) if err != nil { return nil, err } @@ -111,44 +112,51 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { TenantID: tenantID, Balance: balance, FrozenBalance: frozen, + TotalConsumed: totalConsumed, WarningThreshold: static.WarningThreshold, Currency: static.Currency, LowBalanceNotified: static.LowBalanceNotified, }, nil } -// getWalletRedisState 读取 Redis 权威余额/冻结(USD float)。hash 缺失时先灾难恢复重建。 -func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen float64, err error) { - balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID) +// getWalletRedisState 读取 Redis 权威余额/冻结/累计消费(USD float)。hash 缺失时先灾难恢复重建。 +func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen, totalConsumed float64, err error) { + balanceMicro, frozenMicro, consumedMicro, _, exists, err := readWalletHash(ctx, tenantID) if err != nil { - return 0, 0, err + return 0, 0, 0, err } if !exists { if err = rebuildWalletFromDB(ctx, tenantID); err != nil { - return 0, 0, err + return 0, 0, 0, err } - balanceMicro, frozenMicro, _, err = readWalletHash(ctx, tenantID) + balanceMicro, frozenMicro, consumedMicro, _, _, err = readWalletHash(ctx, tenantID) if err != nil { - return 0, 0, err + return 0, 0, 0, err } } - return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), nil + return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), InexactFloat64(FromMicro(consumedMicro)), nil } -// readWalletHash 读取钱包 hash 的 balance/frozen_balance(micro-USD)。 +// readWalletHash 读取钱包 hash 的 balance/frozen_balance/total_consumed(micro-USD)。 // exists=false 表示 hash 不存在(灾难恢复场景)。 -func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro int64, exists bool, err error) { +// hasConsumed=false 表示 hash 存在但缺 total_consumed 字段(部署前的老 hash / 灾后仅含 +// balance 的重建 hash),调用方(物化器)据此从 DB 补种历史基线,避免计数从 0 起算丢历史。 +func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro, consumedMicro int64, hasConsumed, exists bool, err error) { res, err := g.Redis().Do(ctx, "HGETALL", walletHashKey(tenantID)) if err != nil { - return 0, 0, false, gerror.Wrapf(err, "read wallet hash") + return 0, 0, 0, false, false, gerror.Wrapf(err, "read wallet hash") } if res.IsNil() || res.IsEmpty() { - return 0, 0, false, nil + return 0, 0, 0, false, false, nil } m := res.Map() balanceMicro = gconv.Int64(m["balance"]) frozenMicro = gconv.Int64(m["frozen_balance"]) - return balanceMicro, frozenMicro, true, nil + if raw, ok := m["total_consumed"]; ok { + consumedMicro = gconv.Int64(raw) + hasConsumed = true + } + return balanceMicro, frozenMicro, consumedMicro, hasConsumed, true, nil } // rebuildWalletFromDB 灾难恢复:钱包 hash 缺失时从 DB 物化值重建。 @@ -174,11 +182,12 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error { } var w *struct { - Balance decimal.Decimal `json:"balance"` + Balance decimal.Decimal `json:"balance"` + TotalConsumed decimal.Decimal `json:"total_consumed"` } err = dao.BilWallets.Ctx(ctx). Where("tenant_id", tenantID). - Fields("balance"). + Fields("balance, total_consumed"). Scan(&w) if err != nil { return gerror.Wrapf(err, "rebuild wallet: query db") @@ -190,18 +199,19 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error { // 幸存预扣明细重算 frozen(不取 DB frozen_balance:其中可能含已丢失预扣的残留) frozenMicro := sumSurvivingPrededucts(ctx, tenantID) - // 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖 + // 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖。 + // total_consumed 从 DB 物化副本恢复历史基线(丢失的只是物化间隔内的增量,与余额同类风险) rebuildLua := ` local key = KEYS[1] if redis.call("EXISTS", key) == 1 then return 0 end -redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "ver", 0) -redis.call("SADD", "wallet_dirty_tenants", ARGV[3]) +redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "total_consumed", ARGV[3], "ver", 0) +redis.call("SADD", "wallet_dirty_tenants", ARGV[4]) return 1 ` ret, err := g.Redis().Do(ctx, "EVAL", rebuildLua, 1, walletRedisKey, - ToMicro(w.Balance), frozenMicro, tenantID) + ToMicro(w.Balance), frozenMicro, ToMicro(w.TotalConsumed), tenantID) if err != nil { return gerror.Wrapf(err, "rebuild wallet: redis") } @@ -421,6 +431,8 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) (d // 返回值:{claimed_micro, balance_after_micro, frozen_after_micro} // 预扣 hash 不存在(已释放/过期/丢失)时按 claimed=0 处理:只扣 balance 不动 frozen, // 与既有「track 丢失只扣 balance」语义一致(调用方据此打告警)。 +// total_consumed 与扣款同一原子递增(累计消费唯一正向变动点;字段缺失按 0 起算—— +// 正常路径由 boot 种子/灾备重建/物化器自愈三层机制保证字段已含历史基线)。 const settleClaimLua = ` local wallet_key = KEYS[1] local cost = tonumber(ARGV[1]) @@ -444,6 +456,7 @@ if frozen < 0 then frozen = 0 end local balance = tonumber(redis.call("HINCRBY", wallet_key, "balance", -cost)) +redis.call("HINCRBY", wallet_key, "total_consumed", cost) redis.call("HINCRBY", wallet_key, "ver", 1) redis.call("SADD", "wallet_dirty_tenants", tenant_id) return {claimed, balance, frozen} @@ -502,7 +515,8 @@ redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) return {balance, frozen} ` -// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/结算补偿逆转等场景。 +// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/调账等场景。 +// 注意:结算补偿逆转走 ReverseConsumeRedis(需同步回退累计消费),不得用本函数。 // 返回加款后余额与当前冻结快照(供流水 balance_after/frozen_after)。 // hash 缺失时 HINCRBY 会新建仅含 balance 的 hash——仅应发生在「新租户首充」等合法场景; // 其他场景说明发生了灾难丢失,由后续 rebuild/物化流程收敛。 @@ -537,6 +551,32 @@ func DebitWalletRedis(ctx context.Context, tenantID int64, amount decimal.Decima return FromMicro(vals[0]), FromMicro(vals[1]), true, nil } +// 结算补偿逆转 Lua:balance 与 total_consumed 同一原子回退。 +// 专用场景:executeSettlement 第 c 步(记流水)失败时逆转扣款——本单最终免费,不构成消费, +// 累计消费必须与余额同步回退,防止只逆转余额导致计数虚增。 +// 不复用 creditWalletLua:那是充值/调账通用入口,不感知累计消费计数(充值不得动计数)。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = cost_micro(正数) +// ARGV[2] = tenant_id +const reverseConsumeLua = ` +redis.call("HINCRBY", KEYS[1], "balance", ARGV[1]) +redis.call("HINCRBY", KEYS[1], "total_consumed", -tonumber(ARGV[1])) +redis.call("HINCRBY", KEYS[1], "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) +return 1 +` + +// ReverseConsumeRedis 结算补偿逆转(executeSettlement 记流水失败时调用): +// 余额与累计消费在同一 Lua 内原子回退。失败由调用方记 Error 日志告警人工对账。 +func ReverseConsumeRedis(ctx context.Context, tenantID int64, cost decimal.Decimal) error { + _, err := g.Redis().Do(ctx, "EVAL", reverseConsumeLua, 1, + walletHashKey(tenantID), ToMicro(cost), tenantID) + if err != nil { + return gerror.Wrapf(err, "reverse consume: redis") + } + return nil +} + // InvalidateWalletStaticCache 清除钱包静态字段缓存(预警阈值、低余额标记等低频字段变更后调用)。 // 注意:只清进程内静态缓存,【绝不】清 Redis 钱包 hash——hash 是权威余额(非可失效缓存), // 删除会导致下次从滞后的 DB 重建而回滚未物化增量。 diff --git a/internal/logic/billing/wallet_lua_test.go b/internal/logic/billing/wallet_lua_test.go index eee53fd..2eeefba 100644 --- a/internal/logic/billing/wallet_lua_test.go +++ b/internal/logic/billing/wallet_lua_test.go @@ -42,7 +42,7 @@ func testCtx() context.Context { return context.Background() } // readWallet 读取租户钱包余额/冻结(micro → decimal) func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) { t.Helper() - b, f, exists, err := readWalletHash(testCtx(), tenantID) + b, f, _, _, exists, err := readWalletHash(testCtx(), tenantID) if err != nil { t.Fatalf("read wallet hash: %v", err) } @@ -52,6 +52,22 @@ func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) return FromMicro(b), FromMicro(f) } +// readWalletConsumed 读取租户累计消费计数(micro → decimal;hash 缺字段按 0) +func readWalletConsumed(t *testing.T, tenantID int64) decimal.Decimal { + t.Helper() + _, _, c, hasConsumed, exists, err := readWalletHash(testCtx(), tenantID) + if err != nil { + t.Fatalf("read wallet hash: %v", err) + } + if !exists { + t.Fatalf("wallet hash missing for tenant %d", tenantID) + } + if !hasConsumed { + return decimal.Zero + } + return FromMicro(c) +} + // seedWallet 用加款创建指定余额的钱包 hash(顺带校验 CreditWalletRedis 自身) func seedWallet(t *testing.T, tenantID int64, balance decimal.Decimal) { t.Helper() @@ -249,6 +265,7 @@ func TestWalletConcurrency(t *testing.T) { bal, frozen := readWallet(t, tenant) assertDecimalEqual(t, bal, NewFromFloat(100).Sub(NewFromFloat(0.5).Mul(NewFromFloat(n))), "balance conserved (100 - n*0.5)") assertDecimalEqual(t, frozen, decimal.Zero, "frozen converges to zero") + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(0.5).Mul(NewFromFloat(n)), "total_consumed conserved (n*0.5)") } // TestPredeductSweep_RecomputesFrozen 孤儿清扫重算:某预扣 hash 丢失(TTL 过期/崩溃残留)后, @@ -297,3 +314,103 @@ func TestCleanupPreDeduct_RemovesHash(t *testing.T) { t.Fatal("prededuct hash should be cleaned") } } + +// TestSettleClaim_IncrementsTotalConsumed 累计消费随结算扣款同一原子递增; +// 重复结算(预扣已认领、claimed=0 只补扣)时计数仍按实际扣款额累计。 +func TestSettleClaim_IncrementsTotalConsumed(t *testing.T) { + tenant := int64(11) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + + // 首次结算:新 hash 无 total_consumed 字段,HINCRBY 从 0 起算(正常路径由三层补种保证含基线) + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-tc1"}); err != nil { + t.Fatalf("settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(1.5), "total_consumed after first settle") + + // 重复结算走补扣路径:计数继续累计实际扣款 + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-tc1"}); err != nil { + t.Fatalf("second settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(2), "total_consumed accumulates supplement") +} + +// TestReverseConsumeRedis_ReversesBalanceAndCount 结算补偿逆转:余额与累计消费同一原子回退 +func TestReverseConsumeRedis_ReversesBalanceAndCount(t *testing.T) { + tenant := int64(12) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc2", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(3), []string{"req-tc2"}); err != nil { + t.Fatalf("settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(3), "total_consumed after settle") + + // 模拟 executeSettlement 第 c 步失败补偿:本单免费,计数与余额同步回退 + if err := ReverseConsumeRedis(testCtx(), tenant, NewFromFloat(3)); err != nil { + t.Fatalf("reverse consume: %v", err) + } + bal, _ := readWallet(t, tenant) + assertDecimalEqual(t, bal, NewFromFloat(10), "balance restored after reverse") + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "total_consumed reversed with balance") +} + +// TestNonConsumeOps_DoNotTouchTotalConsumed 回归保护: +// 预扣/解冻/扣款(退款调账)/加款(充值)均不得触碰累计消费计数。 +func TestNonConsumeOps_DoNotTouchTotalConsumed(t *testing.T) { + tenant := int64(13) + seedWallet(t, tenant, NewFromFloat(10)) + + if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-nc1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "prededuct must not touch total_consumed") + + if _, err := UnfreezePreDeduct(testCtx(), tenant, "req-nc1"); err != nil { + t.Fatalf("unfreeze: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "unfreeze must not touch total_consumed") + + if _, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3)); err != nil || !ok { + t.Fatalf("debit failed: ok=%v err=%v", ok, err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "debit (refund/adjust) must not touch total_consumed") + + if _, _, err := CreditWalletRedis(testCtx(), tenant, NewFromFloat(5)); err != nil { + t.Fatalf("credit: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "credit (recharge) must not touch total_consumed") +} + +// TestSeedTotalConsumedIfMissing 三层补种之①③层共用 Lua 的行为: +// hash 存在且缺字段 → 写入基线;字段已存在 → 不覆盖(幂等);hash 不存在 → no-op。 +func TestSeedTotalConsumedIfMissing(t *testing.T) { + tenant := int64(14) + seedWallet(t, tenant, NewFromFloat(10)) // creditLua 创建的 hash 不含 total_consumed 字段 + + if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil { + t.Fatalf("seed total_consumed: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(7.25), "seeded from db baseline") + + // 字段已存在后发生结算 → 重复种子不得回退结算增量 + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1), nil); err != nil { + t.Fatalf("settle claim: %v", err) + } + if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil { + t.Fatalf("re-seed: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(8.25), "re-seed must not overwrite post-settle value") + + // hash 不存在的租户 → no-op(不得凭空创建 hash,由 rebuild 流程负责) + missing := int64(15) + if err := seedTotalConsumedIfMissing(testCtx(), missing, ToMicro(NewFromFloat(9))); err != nil { + t.Fatalf("seed on missing hash: %v", err) + } + if _, _, _, hasConsumed, exists, err := readWalletHash(testCtx(), missing); err != nil || exists || hasConsumed { + t.Fatalf("seed must not create hash: exists=%v hasConsumed=%v err=%v", exists, hasConsumed, err) + } +} diff --git a/internal/logic/billing/wallet_seed.go b/internal/logic/billing/wallet_seed.go new file mode 100644 index 0000000..0b5e351 --- /dev/null +++ b/internal/logic/billing/wallet_seed.go @@ -0,0 +1,93 @@ +package billing + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/shopspring/decimal" + + "github.com/qianfree/team-api/internal/dao" +) + +// seedTotalConsumedLua 补种 Lua:仅当 hash 存在且缺 total_consumed 字段时写入历史基线 +// (HSET-if-missing 语义,幂等)。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = total_consumed_micro +const seedTotalConsumedLua = ` +if redis.call("EXISTS", KEYS[1]) == 1 and redis.call("HEXISTS", KEYS[1], "total_consumed") == 0 then + redis.call("HSET", KEYS[1], "total_consumed", ARGV[1]) + return 1 +end +return 0 +` + +// seedTotalConsumedIfMissing 为钱包 hash 补种累计消费基线(hash 不存在或字段已存在均为 no-op)。 +// 供 boot 种子与物化器自愈共用(三层补种机制的①③层)。 +func seedTotalConsumedIfMissing(ctx context.Context, tenantID int64, consumedMicro int64) error { + _, err := g.Redis().Do(ctx, "EVAL", seedTotalConsumedLua, 1, + walletHashKey(tenantID), consumedMicro) + if err != nil { + return gerror.Wrapf(err, "seed total_consumed: redis") + } + return nil +} + +// queryTotalConsumed 读 DB 物化副本中的累计消费(钱包行不存在时返回 0——物化 UPDATE +// 同样影响 0 行,无需区分)。 +func queryTotalConsumed(ctx context.Context, tenantID int64) (decimal.Decimal, error) { + var w *struct { + TotalConsumed decimal.Decimal `json:"total_consumed"` + } + err := dao.BilWallets.Ctx(ctx). + Where("tenant_id", tenantID). + Fields("total_consumed"). + Scan(&w) + if err != nil { + return Zero, gerror.Wrapf(err, "query total_consumed") + } + if w == nil { + return Zero, nil + } + return w.TotalConsumed, nil +} + +// SeedWalletTotalConsumed 启动种子(三层补种之①层):把 DB 物化副本中的累计消费历史基线 +// 种进已存在的钱包 hash。 +// +// 背景:total_consumed 字段上线时,存量钱包 hash(wallet:v2:*)不含该字段,结算 Lua 的 +// HINCRBY 会从 0 起算,丢失迁移回填的历史值。本函数在启动时(relay 流量进入前)为所有 +// total_consumed > 0 的钱包补种,幂等可重跑: +// - hash 已含字段(已种过/已发生结算)→ no-op; +// - hash 不存在(该租户尚无 Redis 钱包)→ 跳过,由 rebuildWalletFromDB 在首次资金操作时补种(②层); +// - 部署后新出现的字段缺失 hash 由物化器自愈兜底(③层)。 +// +// 不 SADD 脏集合——种子值与 DB 相同,无需再物化。 +// DB/Redis 故障时记 Warning 放行(Redis 故障随后由预扣/结算 fail-closed 机制接管,不阻塞启动)。 +// 租户量级为千级以内时逐条 EVAL 足够(秒级完成);量级显著增长时可改 pipeline。 +func SeedWalletTotalConsumed(ctx context.Context) { + var rows []struct { + TenantId int64 `json:"tenant_id"` + TotalConsumed decimal.Decimal `json:"total_consumed"` + } + err := dao.BilWallets.Ctx(ctx). + Fields("tenant_id, total_consumed"). + Where("total_consumed > 0"). + Scan(&rows) + if err != nil { + g.Log().Warningf(ctx, "[WALLET SEED] query wallets for total_consumed seed failed: %v", err) + return + } + + seeded := 0 + for _, row := range rows { + if err := seedTotalConsumedIfMissing(ctx, row.TenantId, ToMicro(row.TotalConsumed)); err != nil { + g.Log().Warningf(ctx, "[WALLET SEED] seed total_consumed failed: tenant=%d: %v", row.TenantId, err) + continue + } + seeded++ + } + if len(rows) > 0 { + g.Log().Infof(ctx, "[WALLET SEED] total_consumed baseline seeded: %d/%d wallets", seeded, len(rows)) + } +} diff --git a/internal/logic/common/audit.go b/internal/logic/common/audit.go index 96adcc0..a6124c6 100644 --- a/internal/logic/common/audit.go +++ b/internal/logic/common/audit.go @@ -7,7 +7,10 @@ import ( "regexp" "strings" + "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/dao" + do "github.com/qianfree/team-api/internal/model/do" ) // Audit levels @@ -33,6 +36,29 @@ func GetAuditConfig(ctx context.Context) (string, error) { return GetAuditLevel(ctx), nil } +// LogSensitiveAccess 记录敏感数据访问(查看 Key 明文、审计详情等"读取"行为)。 +// OperationLog 只覆盖写操作,敏感读取靠本函数留痕;IP/UA 从请求上下文自动提取。 +// 写入失败只记应用日志不阻断业务 —— 留痕是辅助防线,不影响主流程。 +func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason string) { + ip, ua := "", "" + if r := g.RequestFromCtx(ctx); r != nil { + ip = r.GetClientIp() + ua = r.GetHeader("User-Agent") + } + if _, err := AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{ + UserId: userID, + UserType: userType, + ResourceType: resourceType, + ResourceId: resourceID, + Action: action, + Reason: reason, + IpAddress: ip, + UserAgent: ua, + }).Insert(); err != nil { + g.Log().Warningf(ctx, "记录敏感数据访问日志失败: %v", err) + } +} + // GetAuditLevels 返回全局审计级别和租户审计级别,两者完全独立。 // 全局级别从 sys_options 读取,默认 full。 // 租户级别从 tnt_tenants.settings JSONB 读取,未设置时默认 masked。 @@ -74,13 +100,18 @@ func GetTenantAuditLevel(ctx context.Context, tenantID int64) string { // ApplyAuditLevel 根据审计级别处理请求体和响应体,返回处理后的内容。 func ApplyAuditLevel(level string, requestBody, responseBody string, isStream bool, path string) (req, resp string) { - req = requestBody - resp = responseBody + // 多模态请求(对话内联图片/音频、视频参考图)的 base64 载荷先剥离成长度占位: + // multipart 体已在 relay handler 侧被挡成 "[binary body omitted]",但内联在 JSON + // 里的 data URL 会原样落库,单条就能把 aud_request_logs 撑到几 MB。 + // 此处是对话与异步任务两条审计链路的唯一收口点(DataProviderImpl.RecordAudit), + // 在这里剥离即可同时覆盖。none 级别直接丢弃,无需白做一次剥离。 + if level == AuditLevelNone { + return "", "" + } + req = StripInlineMedia(requestBody) + resp = StripInlineMedia(responseBody) switch level { - case AuditLevelNone: - req = "" - resp = "" case AuditLevelQuestionOnly: resp = "" case AuditLevelMasked: diff --git a/internal/logic/common/audit_enrich.go b/internal/logic/common/audit_enrich.go index 2717fd3..5817293 100644 --- a/internal/logic/common/audit_enrich.go +++ b/internal/logic/common/audit_enrich.go @@ -2,14 +2,153 @@ package common import ( "context" - "fmt" + "strconv" + "strings" + "time" "github.com/gogf/gf/v2/frame/g" ) +// 关联名称字典缓存:审计 / 日志列表回填 tenant_name / username / api_key_name / +// project_name 用。 +// +// 这四类都是「改一次、读十万次」的字典数据,而列表页每翻一页都要回填一次, +// 命中缓存后名称回填不再产生任何 DB 查询(原先每页固定 3 次 + 每租户 1 次查询)。 +// +// TTL 300s 与项目既定的「用户信息 300s」缓存策略一致:改名最多滞后一个 TTL。 +// 列表展示的是历史日志,短暂滞后可接受,因此不做写侧的主动失效。 +var nameDictCache = NewCache("namedict", 300*time.Second) + +// 字典种类前缀,与各自 key 拼装函数一起构成缓存 key。 +const ( + nameDictTenant = "tenant" + nameDictUser = "user" + nameDictApiKey = "apikey" + nameDictProject = "project" +) + +// resolveNameDict 批量解析「字典标识 → 名称」:先按标识查缓存(一次 MGET 批量取回), +// 仅对未命中的标识调用 load 查库,查回的名称回填缓存。 +// +// keyOf 把标识编码成缓存 key;load 只接收未命中的标识,且返回的 map 必须只以入参标识为键 +// (不能自行规范化),否则回填的 key 与调用方后续的查询 key 对不上。 +// +// 库里查不到的标识不回填缓存 —— 否则「刚创建、事务尚未提交」的读穿会被固化成 +// 一个长期空值,之后建好也读到空。 +// load 出错时降级为「只返回缓存命中部分」,调用方拿到零值名称,不阻断列表查询。 +// ids 需调用方先去重。 +func resolveNameDict[K comparable]( + ctx context.Context, + label string, + ids []K, + keyOf func(K) string, + load func(ctx context.Context, missing []K) (map[K]string, error), +) map[K]string { + if len(ids) == 0 { + return nil + } + + cacheKeys := make([]string, len(ids)) + for i, id := range ids { + cacheKeys[i] = keyOf(id) + } + + cached := nameDictCache.GetMany(ctx, cacheKeys) + result := make(map[K]string, len(ids)) + missing := make([]K, 0, len(ids)) + for i, id := range ids { + if name, ok := cached[cacheKeys[i]].(string); ok && name != "" { + result[id] = name + continue + } + missing = append(missing, id) + } + if len(missing) == 0 { + return result + } + + loaded, err := load(ctx, missing) + if err != nil { + g.Log().Warningf(ctx, "[NameDict] 查询 %s 名称失败: %v", label, err) + return result + } + if len(loaded) == 0 { + return result + } + + backfill := make(map[string]any, len(loaded)) + for id, name := range loaded { + result[id] = name + backfill[keyOf(id)] = name + } + nameDictCache.SetMany(ctx, backfill) + return result +} + +// idNameDictKey int64 主键类的字典缓存 key,形如 "tenant:12"。 +func idNameDictKey(kind string, id int64) string { + return kind + ":" + strconv.FormatInt(id, 10) +} + +// resolveInt64NameDict 租户 / API Key / 项目三类同构字典的解析入口(主键均为 int64)。 +func resolveInt64NameDict( + ctx context.Context, + kind string, + ids []int64, + load func(ctx context.Context, missing []int64) (map[int64]string, error), +) map[int64]string { + return resolveNameDict(ctx, kind, ids, func(id int64) string { + return idNameDictKey(kind, id) + }, load) +} + +// nameDictUserKey 用户字典缓存 key,形如 "user:3:45"。 +// 租户维度必须编入 key:只按 user_id 缓存的话,A 租户的用户名会在 B 租户的记录上命中, +// 等于绕过了「按 ID 取对象用双键校验」的隔离约定(租户控制台也会回填这些名称)。 +func nameDictUserKey(tenantID, userID int64) string { + return nameDictUser + ":" + pairKey(tenantID, userID) +} + +// pairKey 拼装 "tenantID:userID"。 +func pairKey(tenantID, userID int64) string { + return strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(userID, 10) +} + +// uniqueInt64s 去重并保持首次出现的顺序(丢弃非正值)。 +func uniqueInt64s(ids []int64) []int64 { + seen := make(map[int64]bool, len(ids)) + result := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + return result +} + +// parseTenantUserKey 拆解 "tenantID:userID" 复合 key。 +func parseTenantUserKey(key string) (tenantID, userID int64, ok bool) { + head, tail, found := strings.Cut(key, ":") + if !found { + return 0, 0, false + } + tenantID, err := strconv.ParseInt(head, 10, 64) + if err != nil || tenantID <= 0 { + return 0, 0, false + } + userID, err = strconv.ParseInt(tail, 10, 64) + if err != nil || userID <= 0 { + return 0, 0, false + } + return tenantID, userID, true +} + // EnrichAuditRecords 为审计记录批量补充关联信息(用户名、租户名、API Key 名、项目名)。 // records 中的每条记录应包含 tenant_id、user_id、api_key_id、project_id 字段, // 函数会从主库批量查询关联信息并回填 tenant_name、username、api_key_name、project_name 字段。 +// 四类名称各自最多一次查库(且仅在缓存未命中时),与记录条数无关。 func EnrichAuditRecords(ctx context.Context, records []map[string]any) { if len(records) == 0 { return @@ -45,158 +184,179 @@ func EnrichAuditRecords(ctx context.Context, records []map[string]any) { } // BatchQueryUserNames 批量查询用户名。 -// key 格式为 "tenantID:userID",返回 map[key]username。 +// 入参 key 格式为 "tenantID:userID";返回 map[key]username。 func BatchQueryUserNames(ctx context.Context, userKeys []string) map[string]string { - if len(userKeys) == 0 { - return nil - } - result := make(map[string]string, len(userKeys)) - // 去重 + // 先滤掉非法键:它们既不该进缓存也不该查库 + keys := make([]string, 0, len(userKeys)) seen := make(map[string]bool, len(userKeys)) - unique := make([]string, 0, len(userKeys)) - for _, k := range userKeys { - if !seen[k] { - seen[k] = true - unique = append(unique, k) - } - } - - // 拆分 tenantID 和 userID,按 tenantID 分组批量查询 - tenantUsers := make(map[int64][]int64) // tenantID -> []userID - userKeyToID := make(map[string]int64) // "tenantID:userID" -> userID - for _, key := range unique { - var tenantID, userID int64 - fmt.Sscanf(key, "%d:%d", &tenantID, &userID) - if tenantID > 0 && userID > 0 { - tenantUsers[tenantID] = append(tenantUsers[tenantID], userID) - userKeyToID[key] = userID - } - } - - // 按 tenantID 分组查询 - for tenantID, userIDs := range tenantUsers { - type row struct { - Id int64 `json:"id"` - Username string `json:"username"` - } - var rows []row - err := g.DB().Ctx(ctx).Model("tnt_users"). - Where("tenant_id", tenantID). - WhereIn("id", userIDs). - Fields("id, username"). - Scan(&rows) - if err != nil { + for _, key := range userKeys { + if _, _, ok := parseTenantUserKey(key); !ok { continue } - for _, r := range rows { - mapKey := fmt.Sprintf("%d:%d", tenantID, r.Id) - result[mapKey] = r.Username + if seen[key] { + continue } + seen[key] = true + keys = append(keys, key) + } + if len(keys) == 0 { + return nil } - return result + return resolveNameDict(ctx, nameDictUser, keys, + func(key string) string { + // keys 已过滤,必定可解析(忽略 ok) + tenantID, userID, _ := parseTenantUserKey(key) + return nameDictUserKey(tenantID, userID) + }, + loadUserNames) +} + +// loadUserNames 按 (tenant_id, user_id) 精确配对批量查询用户名。 +// 先用 tenant_id IN (...) AND id IN (...) 缩小范围,再在应用层按配对过滤 —— +// 不用「只按 id 查」,否则 user_id 与 tenant_id 不匹配的脏记录会把其他租户的用户名带出来, +// 与「按 ID 取对象用双键校验」的隔离约定相悖。 +func loadUserNames(ctx context.Context, keys []string) (map[string]string, error) { + type pair struct{ tenantID, userID int64 } + + pairs := make([]pair, 0, len(keys)) + tenantIDs := make([]int64, 0, len(keys)) + userIDs := make([]int64, 0, len(keys)) + for _, key := range keys { + tenantID, userID, ok := parseTenantUserKey(key) + if !ok { + continue + } + pairs = append(pairs, pair{tenantID, userID}) + tenantIDs = append(tenantIDs, tenantID) + userIDs = append(userIDs, userID) + } + if len(pairs) == 0 { + return nil, nil + } + + type row struct { + Id int64 `json:"id"` + TenantId int64 `json:"tenant_id"` + Username string `json:"username"` + } + var rows []row + err := g.DB().Ctx(ctx).Model("tnt_users"). + WhereIn("tenant_id", uniqueInt64s(tenantIDs)). + WhereIn("id", uniqueInt64s(userIDs)). + Fields("id, tenant_id, username"). + Scan(&rows) + if err != nil { + return nil, err + } + + nameByPair := make(map[string]string, len(rows)) + for _, r := range rows { + nameByPair[pairKey(r.TenantId, r.Id)] = r.Username + } + + // 以入参 key 原样为返回键(回填缓存时 keyOf 也据此重算),不做规范化改写 + result := make(map[string]string, len(keys)) + for _, key := range keys { + tenantID, userID, ok := parseTenantUserKey(key) + if !ok { + continue + } + if name, found := nameByPair[pairKey(tenantID, userID)]; found { + result[key] = name + } + } + return result, nil } // BatchQueryTenantNames 批量查询租户名。 func BatchQueryTenantNames(ctx context.Context, tenantIDs []int64) map[int64]string { - if len(tenantIDs) == 0 { + ids := uniqueInt64s(tenantIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(tenantIDs)) - // 去重 - seen := make(map[int64]bool, len(tenantIDs)) - unique := make([]int64, 0, len(tenantIDs)) - for _, id := range tenantIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictTenant, ids, loadTenantNames) +} +// loadTenantNames 从主库按主键批量查询租户名。 +func loadTenantNames(ctx context.Context, tenantIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("tnt_tenants"). - WhereIn("id", unique). + WhereIn("id", tenantIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // BatchQueryApiKeyNames 批量查询 API Key 名。 func BatchQueryApiKeyNames(ctx context.Context, apiKeyIDs []int64) map[int64]string { - if len(apiKeyIDs) == 0 { + ids := uniqueInt64s(apiKeyIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(apiKeyIDs)) - // 去重 - seen := make(map[int64]bool, len(apiKeyIDs)) - unique := make([]int64, 0, len(apiKeyIDs)) - for _, id := range apiKeyIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictApiKey, ids, loadApiKeyNames) +} +// loadApiKeyNames 从主库按主键批量查询 API Key 名。 +func loadApiKeyNames(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("api_keys"). - WhereIn("id", unique). + WhereIn("id", apiKeyIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // BatchQueryProjectNames 批量查询项目名。 func BatchQueryProjectNames(ctx context.Context, projectIDs []int64) map[int64]string { - if len(projectIDs) == 0 { + ids := uniqueInt64s(projectIDs) + if len(ids) == 0 { return nil } - result := make(map[int64]string, len(projectIDs)) - // 去重 - seen := make(map[int64]bool, len(projectIDs)) - unique := make([]int64, 0, len(projectIDs)) - for _, id := range projectIDs { - if !seen[id] { - seen[id] = true - unique = append(unique, id) - } - } + return resolveInt64NameDict(ctx, nameDictProject, ids, loadProjectNames) +} +// loadProjectNames 从主库按主键批量查询项目名。 +func loadProjectNames(ctx context.Context, projectIDs []int64) (map[int64]string, error) { type row struct { Id int64 `json:"id"` Name string `json:"name"` } var rows []row err := g.DB().Ctx(ctx).Model("tnt_projects"). - WhereIn("id", unique). + WhereIn("id", projectIDs). Fields("id, name"). Scan(&rows) if err != nil { - return result + return nil, err } + result := make(map[int64]string, len(rows)) for _, r := range rows { result[r.Id] = r.Name } - return result + return result, nil } // collectInt64Fields 从记录集合中收集指定字段的所有非零 int64 值(去重)。 @@ -250,5 +410,5 @@ func getUserKey(record map[string]any) (string, bool) { if !ok1 || !ok2 || tenantID == 0 || userID == 0 { return "", false } - return fmt.Sprintf("%d:%d", tenantID, userID), true + return pairKey(tenantID, userID), true } diff --git a/internal/logic/common/audit_enrich_test.go b/internal/logic/common/audit_enrich_test.go new file mode 100644 index 0000000..5d0e781 --- /dev/null +++ b/internal/logic/common/audit_enrich_test.go @@ -0,0 +1,108 @@ +package common + +import ( + "reflect" + "testing" +) + +// 说明:resolveNameDict / BatchQuery* 的缓存路径依赖 Redis(g.Redis() 在未配置时 panic, +// 单测环境无 Redis 配置),故此处只覆盖不触达缓存的纯逻辑: +// key 拼装、复合 key 解析、ID 去重。缓存命中/回填行为由集成环境验证。 + +func TestIdNameDictKey(t *testing.T) { + cases := []struct { + kind string + id int64 + want string + }{ + {nameDictTenant, 12, "tenant:12"}, + {nameDictApiKey, 7, "apikey:7"}, + {nameDictProject, 0, "project:0"}, + {nameDictTenant, -3, "tenant:-3"}, + } + for _, c := range cases { + if got := idNameDictKey(c.kind, c.id); got != c.want { + t.Errorf("idNameDictKey(%q, %d) = %q, want %q", c.kind, c.id, got, c.want) + } + } +} + +// TestNameDictUserKeyIsTenantScoped 用户字典 key 必须带上租户维度: +// 只按 user_id 缓存会让 A 租户的用户名在 B 租户的记录上命中(跨租户名称泄漏)。 +func TestNameDictUserKeyIsTenantScoped(t *testing.T) { + if got := nameDictUserKey(3, 45); got != "user:3:45" { + t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, "user:3:45") + } + sameUserDifferentTenant := nameDictUserKey(5, 45) + if nameDictUserKey(3, 45) == sameUserDifferentTenant { + t.Fatalf("同一 user_id 在不同租户下必须得到不同缓存 key,均得到 %q", sameUserDifferentTenant) + } +} + +// TestPairKeyMatchesUserKeySuffix loadUserNames 用 pairKey 建索引、nameDictUserKey 拼缓存 key, +// 两者必须共用同一套 "tenantID:userID" 编码,否则回填的 key 与查询 key 对不上。 +func TestPairKeyMatchesUserKeySuffix(t *testing.T) { + if got, want := nameDictUserKey(3, 45), nameDictUser+":"+pairKey(3, 45); got != want { + t.Fatalf("nameDictUserKey(3, 45) = %q, want %q", got, want) + } +} + +// TestParseTenantUserKey 合法键正常拆解;非法键一律拒绝(BatchQueryUserNames 据此静默丢弃, +// 不能把错键当成 tenant 0 / user 0 去查库)。 +func TestParseTenantUserKey(t *testing.T) { + valid := []struct { + key string + tenantID int64 + userID int64 + }{ + {"3:45", 3, 45}, + {"100:1", 100, 1}, + } + for _, c := range valid { + tenantID, userID, ok := parseTenantUserKey(c.key) + if !ok || tenantID != c.tenantID || userID != c.userID { + t.Errorf("parseTenantUserKey(%q) = (%d, %d, %v), want (%d, %d, true)", + c.key, tenantID, userID, ok, c.tenantID, c.userID) + } + } + + invalid := []string{ + "", // 空串 + "45", // 无分隔符 + ":45", // 缺 tenantID + "3:", // 缺 userID + "0:45", // tenantID 非正 + "3:0", // userID 非正 + "-3:45", // tenantID 非正 + "3:-45", // userID 非正 + "a:45", // 非数字 + "3:b", // 非数字 + "3:45:6", // 多余段(tail 非数字) + " 3:45", // 前导空格 + "2147483648:99999999999999999999", // 溢出 + } + for _, key := range invalid { + if tenantID, userID, ok := parseTenantUserKey(key); ok { + t.Errorf("parseTenantUserKey(%q) 应被拒绝,却返回 (%d, %d, true)", key, tenantID, userID) + } + } +} + +// TestUniqueInt64s 去重保持首次出现顺序,并丢弃非正值(0 / 负数不是合法主键)。 +func TestUniqueInt64s(t *testing.T) { + cases := []struct { + name string + in []int64 + want []int64 + }{ + {"去重保序", []int64{5, 3, 5, 1, 3}, []int64{5, 3, 1}}, + {"丢弃非正值", []int64{0, -1, 2, 0}, []int64{2}}, + {"全非正", []int64{0, -1}, []int64{}}, + {"空入参", nil, []int64{}}, + } + for _, c := range cases { + if got := uniqueInt64s(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: uniqueInt64s(%v) = %v, want %v", c.name, c.in, got, c.want) + } + } +} diff --git a/internal/logic/common/audit_log_writer.go b/internal/logic/common/audit_log_writer.go index 6e549d4..9f426f9 100644 --- a/internal/logic/common/audit_log_writer.go +++ b/internal/logic/common/audit_log_writer.go @@ -1,6 +1,7 @@ package common import ( + "context" "time" ) @@ -10,12 +11,22 @@ var DefaultAuditLogWriter *UsageLogWriter // InitAuditLogWriter 初始化审计日志批量写入器 // 审计日志与大模型请求 1:1,量级与 bil_usage_logs 相当,必须走批量通道避免单条 INSERT 打爆审计库。 // 配置了 database.audit 独立审计库时经 DB getter 路由过去,否则回退主库(与 AuditModelCtx 一致)。 -func InitAuditLogWriter() { +// 批量参数来自「性能配置-批量写入」(sys_options,重启后生效);未配置或非法时回落内置默认。 +func InitAuditLogWriter(ctx context.Context) { + batchSize := Config().GetInt(ctx, "batch_write_size") + if batchSize <= 0 { + batchSize = 64 + } + flushInterval := time.Duration(Config().GetInt(ctx, "batch_write_interval_ms")) * time.Millisecond + if flushInterval <= 0 { + flushInterval = 1 * time.Second + } + DefaultAuditLogWriter = NewUsageLogWriter(UsageLogWriterConfig{ Table: "aud_request_logs", QueueSize: 8192, - BatchSize: 64, - FlushInterval: 1 * time.Second, + BatchSize: batchSize, + FlushInterval: flushInterval, Workers: 2, Overflow: OverflowSyncFallback, DB: GetAuditDB, diff --git a/internal/logic/common/cache.go b/internal/logic/common/cache.go index 699e0ff..6130dc6 100644 --- a/internal/logic/common/cache.go +++ b/internal/logic/common/cache.go @@ -83,6 +83,95 @@ func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Dura } } +// SetMany 批量写入 L1(内存)+ L2(Redis),语义与逐 key 调用 Set 一致。 +// L1 逐 key 写本进程内存;L2 逐 key SETEX —— Redis 没有「批量且带过期」的原生命令 +// (MSET 不支持 TTL),故写侧仍是 N 次往返。该方法只服务于「缓存未命中后回填」, +// 调用频次受 TTL 约束,不在热路径上;热路径的批量读走 GetMany(单次 MGET)。 +func (c *Cache) SetMany(ctx context.Context, values map[string]any) { + if len(values) == 0 { + return + } + expire := jitterTTL(c.ttl) + ttlSeconds := int64(math.Ceil(expire.Seconds())) + if ttlSeconds <= 0 { + // 兜底:ttl ≤ 0 时使用 24h 兜底 TTL,避免产生永久 key(与 Set 一致) + g.Log().Warningf(ctx, "[Cache] TTL <= 0 for prefix=%s, using fallback 24h TTL", c.prefix) + ttlSeconds = 86400 + } + + // L1 先整体落地:即使 L2 写入失败,本进程后续读取也能立即命中 + for key, value := range values { + gcache.Set(ctx, c.fullKey(key), value, expire) + } + + for key, value := range values { + fullKey := c.fullKey(key) + jsonBytes, err := json.Marshal(value) + if err != nil { + g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err) + continue + } + if _, err = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes)); err != nil { + g.Log().Warningf(ctx, "[Cache] SETEX failed key=%s: %v", fullKey, err) + } + } +} + +// GetMany 批量读取:先逐 key 查 L1(gcache,命中即零网络开销), +// 未命中的 key 用一次 MGET 走 L2(Redis),命中 L2 的值回填 L1。 +// 返回「命中的原始 key → 值」,未命中的 key 不出现在结果中。 +// key 为不含前缀的原始 key(与 Get/Set 一致)。 +// +// 为什么必须批量:审计/日志列表每页要回填几十个关联名称,逐 key Get 会让冷路径 +// 产生 N 次 Redis 往返,反而比直接查库更慢;MGET 把冷路径压成 1 次往返。 +// Redis 不可用或 MGET 失败时只返回 L1 命中部分,未命中部分由调用方回落查库。 +func (c *Cache) GetMany(ctx context.Context, keys []string) map[string]any { + result := make(map[string]any, len(keys)) + if len(keys) == 0 { + return result + } + + missKeys := make([]string, 0, len(keys)) + missFullKeys := make([]string, 0, len(keys)) + for _, key := range keys { + fullKey := c.fullKey(key) + if val, err := gcache.Get(ctx, fullKey); err == nil && val != nil { + result[key] = val.Interface() + continue + } + missKeys = append(missKeys, key) + missFullKeys = append(missFullKeys, fullKey) + } + if len(missFullKeys) == 0 { + return result + } + + // L2:一次 MGET 取回全部未命中 key(缺失 key 在返回 map 中为 nil 值) + l2Values, err := g.Redis().MGet(ctx, missFullKeys...) + if err != nil { + g.Log().Warningf(ctx, "[Cache] MGET failed prefix=%s: %v", c.prefix, err) + return result + } + for i, fullKey := range missFullKeys { + v, ok := l2Values[fullKey] + if !ok || v == nil || v.IsNil() { + continue + } + jsonStr := v.String() + if jsonStr == "" { + continue + } + // 与 Get 一致:优先按 JSON 反序列化还原原始类型,非 JSON 的历史值按纯字符串兜底 + var raw any + if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr != nil { + raw = jsonStr + } + gcache.Set(ctx, fullKey, raw, jitterTTL(c.ttl)) + result[missKeys[i]] = raw + } + return result +} + // Get retrieves a value: L1 → L2 → miss. // L1 returns native Go types. L2 deserializes JSON back to the original type. func (c *Cache) Get(ctx context.Context, key string) (any, bool) { diff --git a/internal/logic/common/config.go b/internal/logic/common/config.go index 18d72a4..4a060a4 100644 --- a/internal/logic/common/config.go +++ b/internal/logic/common/config.go @@ -225,7 +225,9 @@ func (s *ConfigService) SetOption(ctx context.Context, key, value string) error // 本位币只在首次写入(系统初始化向导)时允许设置,此后只读; // 相同值放行,保证设置页保存整个 payment 分类时不被误拒。 if key == consts.OptionKeyBillingCurrency && count > 0 { - existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Value() + // 必须显式 Fields("value"):不带字段的 Value() 返回结果集第一列(即 id), + // 会导致"相同值放行"判断永远失败,支付分类保存必报"本位币不可更改" + existing, err := dao.SysOptions.Ctx(ctx).Where("key", key).Fields("value").Value() if err != nil { return err } diff --git a/internal/logic/common/help_center.go b/internal/logic/common/help_center.go index cd38bfa..d952d21 100644 --- a/internal/logic/common/help_center.go +++ b/internal/logic/common/help_center.go @@ -2,8 +2,13 @@ package common import ( "context" + "fmt" + "strings" + "time" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" ) @@ -65,10 +70,12 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error countMap[cr.CategoryId] = cr.ArticleCount } - // 构建所有节点 - nodeMap := make(map[int64]*HelpPublicCategoryItem) + // 构建所有节点(rows 已按 sort_order 排序,同时维护有序切片保证树节点顺序稳定, + // 不能遍历 map 组装——Go map 遍历顺序随机,会导致分类顺序每次请求都变) + nodeMap := make(map[int64]*HelpPublicCategoryItem, len(rows)) + orderedNodes := make([]*HelpPublicCategoryItem, 0, len(rows)) for _, r := range rows { - nodeMap[r.Id] = &HelpPublicCategoryItem{ + node := &HelpPublicCategoryItem{ Id: r.Id, ParentId: r.ParentId, Name: r.Name, @@ -78,11 +85,13 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error ArticleCount: countMap[r.Id], Children: make([]*HelpPublicCategoryItem, 0), } + nodeMap[r.Id] = node + orderedNodes = append(orderedNodes, node) } - // 组装树结构 + // 组装树结构(按 sort_order 顺序遍历,roots 与 children 均保持稳定排序) var roots []*HelpPublicCategoryItem - for _, node := range nodeMap { + for _, node := range orderedNodes { if node.ParentId == 0 { roots = append(roots, node) } else if parent, ok := nodeMap[node.ParentId]; ok { @@ -120,18 +129,19 @@ func ListPublicArticles(ctx context.Context, categorySlug string, page, pageSize return nil, 0, 0, 0, err } if cat == nil { - return nil, 0, 0, 0, NewBusinessError(10073, "帮助分类不存在") + return nil, 0, 0, 0, NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound) } var total int rows := make([]*HelpPublicArticleItem, 0) + // 排序与管理后台列表保持一致:sort_order 优先,其次发布时间倒序 err = dao.SptArticles.Ctx(ctx). Where("category_id", cat.Id). Where("status", "published"). Where("published_at IS NOT NULL"). Where("published_at <=", gtime.Now()). - OrderDesc("published_at"). OrderAsc("sort_order"). + OrderDesc("published_at"). Page(page, pageSize). ScanAndCount(&rows, &total, false) if err != nil { @@ -177,16 +187,18 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai return nil, err } if row.Id == 0 { - return nil, NewBusinessError(10075, "帮助文章不存在") + return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound) } if row.Status != "published" || row.PublishedAt == nil || row.PublishedAt.Timestamp() > gtime.Now().Timestamp() { - return nil, NewBusinessError(10075, "帮助文章不存在") + return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound) } - // 增加浏览计数 - dao.SptArticles.Ctx(ctx). + // 增加浏览计数(失败仅记日志,不影响详情返回) + if _, err = dao.SptArticles.Ctx(ctx). Where("id", row.Id). - Increment("view_count", 1) + Increment("view_count", 1); err != nil { + g.Log().Warningf(ctx, "帮助文章浏览计数失败 id=%d: %v", row.Id, err) + } return &HelpPublicArticleDetail{ Id: row.Id, @@ -201,18 +213,23 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai }, nil } -// SearchPublicArticles 全文搜索已发布文章 +// SearchPublicArticles 搜索已发布文章 +// 用 ILIKE 模糊匹配而非 to_tsvector 全文检索:PostgreSQL 'simple' 分词配置不做中文分词, +// 连续中文会被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。 +// 帮助文章量级通常为百级,ILIKE 顺序扫描性能足够。 func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) ([]*HelpPublicArticleItem, int, int, int, error) { page, pageSize = NormalizePagination(page, pageSize) + likePat := "%" + escapeLikePattern(query) + "%" var total int rows := make([]*HelpPublicArticleItem, 0) err := dao.SptArticles.Ctx(ctx). Where("status", "published"). Where("published_at IS NOT NULL"). Where("published_at <=", gtime.Now()). - Where("to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(content,'')) @@ plainto_tsquery('simple', ?)", query). + Where("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')", likePat, likePat). OrderDesc("published_at"). + OrderAsc("sort_order"). Page(page, pageSize). ScanAndCount(&rows, &total, false) if err != nil { @@ -221,3 +238,33 @@ func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) return rows, total, page, pageSize, nil } + +// escapeLikePattern 转义 ILIKE 模式中的通配符(配合 ESCAPE '\' 使用),防止用户输入 % _ \ 干扰匹配 +func escapeLikePattern(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + +// CheckHelpCenterRateLimit 帮助中心公开接口 IP 限流(公开无认证端点,防刷浏览量/搜索滥用) +// 默认每 IP 每分钟 60 次,可通过配置 help_center_rate_limit_per_minute 调整;Redis 不可用时放行(帮助中心为公开只读内容) +func CheckHelpCenterRateLimit(ctx context.Context, ipAddress string) error { + if ipAddress == "" { + return nil + } + limit := Config().GetInt(ctx, "help_center_rate_limit_per_minute") + if limit <= 0 { + limit = 60 + } + key := fmt.Sprintf("help:center:rl:%s:%d", ipAddress, time.Now().Unix()/60) + count, err := incrementWithExpire(ctx, key, 120) + if err != nil { + g.Log().Warningf(ctx, "帮助中心限流检查失败: %v", err) + return nil + } + if count > int64(limit) { + return NewBusinessError(consts.CodeHelpRateLimitExceeded, consts.MsgHelpRateLimitExceeded) + } + return nil +} diff --git a/internal/logic/common/inline_media.go b/internal/logic/common/inline_media.go new file mode 100644 index 0000000..c0823f0 --- /dev/null +++ b/internal/logic/common/inline_media.go @@ -0,0 +1,97 @@ +package common + +import ( + "bytes" + "strconv" + "strings" +) + +// 内联媒体(data URL)处理工具。 +// +// 多模态请求体里的图片/音频以 `data:;base64,<载荷>` 的形式内联在 JSON 中, +// 单个载荷动辄数 MB。这类载荷对三条链路都是纯负担: +// - 预扣估算按字节折算会把一张图估成几十万 token,直接冻穿租户余额 +// - 审计落库会把整段 base64 灌进 aud_request_logs +// - 敏感词匹配白扫几 MB,还可能在 base64 串里误命中 +// +// 这里提供统一的扫描与剥离能力,三处共用,避免各写一份。 + +// inlineMediaMarker data URL 中 base64 载荷的起始标记。 +const inlineMediaMarker = ";base64," + +// inlineMediaMinPayload 认定为内联媒体的最小载荷长度。 +// 真实的图片/音频 base64 远大于此,设下限是为了不误伤正文里恰好出现该标记的短文本。 +const inlineMediaMinPayload = 64 + +// ScanInlineMediaBytes 扫描内容中的 data URL base64 载荷,返回载荷总字节数与媒体个数。 +// +// 纯字节扫描不做 JSON 反序列化——调用点在请求热路径上,且请求体可能有数 MB。 +// base64 字母表不含 `"` 与 `\`,故以二者作为 JSON 字符串内载荷的终止符。 +func ScanInlineMediaBytes(b []byte) (payloadBytes, count int) { + marker := []byte(inlineMediaMarker) + rest := b + for { + idx := bytes.Index(rest, marker) + if idx < 0 { + return + } + rest = rest[idx+len(marker):] + end := bytes.IndexAny(rest, "\"\\") + if end < 0 { + end = len(rest) + } + if end >= inlineMediaMinPayload { + payloadBytes += end + count++ + } + rest = rest[end:] + } +} + +// StripInlineMediaBytes 是 StripInlineMedia 的字节切片版本。 +// 未命中内联媒体时原样返回入参切片,不产生任何拷贝——请求热路径上的绝大多数 +// 请求体是纯文本,这条快路径让它们零开销通过。 +func StripInlineMediaBytes(b []byte) []byte { + if !bytes.Contains(b, []byte(inlineMediaMarker)) { + return b + } + return []byte(StripInlineMedia(string(b))) +} + +// StripInlineMedia 把 base64 载荷替换成长度占位,保留 mime 前缀便于排查: +// +// data:image/png;base64,iVBORw0KGgo... → data:image/png;base64,<省略 2097152 字节> +// +// 未命中任何内联媒体时原样返回,不产生额外分配。 +func StripInlineMedia(s string) string { + if !strings.Contains(s, inlineMediaMarker) { + return s + } + + var sb strings.Builder + rest := s + for { + idx := strings.Index(rest, inlineMediaMarker) + if idx < 0 { + sb.WriteString(rest) + break + } + head := idx + len(inlineMediaMarker) + sb.WriteString(rest[:head]) + rest = rest[head:] + + end := strings.IndexAny(rest, "\"\\") + if end < 0 { + end = len(rest) + } + if end >= inlineMediaMinPayload { + sb.WriteString("<省略 ") + sb.WriteString(strconv.Itoa(end)) + sb.WriteString(" 字节>") + } else { + sb.WriteString(rest[:end]) + } + rest = rest[end:] + } + return sb.String() +} diff --git a/internal/logic/common/inline_media_test.go b/internal/logic/common/inline_media_test.go new file mode 100644 index 0000000..b5b6a8f --- /dev/null +++ b/internal/logic/common/inline_media_test.go @@ -0,0 +1,119 @@ +package common + +import ( + "strings" + "testing" +) + +// bigPayload 构造一段长度足够被认定为内联媒体的 base64 载荷。 +func bigPayload(n int) string { + return strings.Repeat("A", n) +} + +func TestScanInlineMediaBytes(t *testing.T) { + payload := bigPayload(400) + cases := []struct { + name string + body string + wantBytes int + wantCount int + }{ + {"纯文本", `{"model":"gpt-4o","messages":[{"role":"user","content":"你好"}]}`, 0, 0}, + {"空体", "", 0, 0}, + { + "单图", + `{"content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,` + payload + `"}}]}`, + 400, 1, + }, + { + "双图", + `{"a":"data:image/png;base64,` + payload + `","b":"data:image/jpeg;base64,` + payload + `"}`, + 800, 2, + }, + { + "音频", + `{"input_audio":{"data":"data:audio/mp3;base64,` + payload + `","format":"mp3"}}`, + 400, 1, + }, + // 载荷过短:正文里恰好出现该标记,不应被当作媒体扣减 + {"短载荷不计入", `{"text":"写法是 ;base64,abc 这样"}`, 0, 0}, + // 无结束引号(截断的请求体):载荷取到末尾 + {"未闭合", `{"url":"data:image/png;base64,` + payload, 400, 1}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotBytes, gotCount := ScanInlineMediaBytes([]byte(c.body)) + if gotBytes != c.wantBytes || gotCount != c.wantCount { + t.Errorf("ScanInlineMediaBytes() = (%d, %d), want (%d, %d)", + gotBytes, gotCount, c.wantBytes, c.wantCount) + } + }) + } +} + +func TestStripInlineMedia(t *testing.T) { + payload := bigPayload(400) + + t.Run("剥离载荷保留 mime 前缀", func(t *testing.T) { + in := `{"url":"data:image/png;base64,` + payload + `","prompt":"描述这张图"}` + got := StripInlineMedia(in) + if strings.Contains(got, payload) { + t.Error("base64 载荷未被剥离") + } + if !strings.Contains(got, "data:image/png;base64,<省略 400 字节>") { + t.Errorf("占位文案不符: %s", got) + } + if !strings.Contains(got, `"prompt":"描述这张图"`) { + t.Error("载荷之外的内容被破坏") + } + }) + + t.Run("纯文本原样返回", func(t *testing.T) { + in := `{"model":"gpt-4o","messages":[{"role":"user","content":"你好"}]}` + if got := StripInlineMedia(in); got != in { + t.Errorf("StripInlineMedia() = %q, want 原样返回", got) + } + }) + + t.Run("多个载荷全部剥离", func(t *testing.T) { + in := `{"a":"data:image/png;base64,` + payload + `","b":"data:image/jpeg;base64,` + payload + `"}` + got := StripInlineMedia(in) + if strings.Contains(got, payload) { + t.Error("仍残留 base64 载荷") + } + if n := strings.Count(got, "<省略 400 字节>"); n != 2 { + t.Errorf("占位数量 = %d, want 2", n) + } + }) + + t.Run("短载荷保持原样", func(t *testing.T) { + in := `{"text":"写法是 ;base64,abc 这样"}` + if got := StripInlineMedia(in); got != in { + t.Errorf("StripInlineMedia() = %q, want 原样返回", got) + } + }) + + t.Run("剥离后可被字节版复用且无拷贝", func(t *testing.T) { + plain := []byte(`{"content":"纯文本"}`) + got := StripInlineMediaBytes(plain) + if &got[0] != &plain[0] { + t.Error("纯文本路径应原样返回入参切片,不做拷贝") + } + }) +} + +func TestApplyAuditLevelStripsInlineMedia(t *testing.T) { + payload := bigPayload(400) + body := `{"url":"data:image/png;base64,` + payload + `"}` + + req, _ := ApplyAuditLevel(AuditLevelFull, body, "", false, "/v1/chat/completions") + if strings.Contains(req, payload) { + t.Error("full 级别仍把 base64 载荷落库") + } + + req, resp := ApplyAuditLevel(AuditLevelNone, body, body, false, "/v1/chat/completions") + if req != "" || resp != "" { + t.Error("none 级别应返回空") + } +} diff --git a/internal/logic/common/jwt.go b/internal/logic/common/jwt.go index eee9f3a..c0b8948 100644 --- a/internal/logic/common/jwt.go +++ b/internal/logic/common/jwt.go @@ -6,6 +6,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "strconv" + "strings" "time" "github.com/gogf/gf/v2/errors/gerror" @@ -159,6 +161,36 @@ func GenerateRefreshToken() (string, error) { return hex.EncodeToString(bytes), nil } +// BindSessionIDToRefreshToken 在刷新令牌尾部附加会话 ID(格式:random.sessionID)。 +// 附加段不参与哈希存储(库中存的是随机段的哈希),仅用于令牌被轮换后仍能定位 +// 所属会话 —— 重放检测的前提。随机段为纯 hex,"." 是无歧义分隔符。 +func BindSessionIDToRefreshToken(token string, sessionID int64) string { + return fmt.Sprintf("%s.%d", token, sessionID) +} + +// SplitRefreshToken 拆出刷新令牌的随机段与内嵌会话 ID。 +// ok=false 表示存量旧格式令牌(纯随机段、无会话 ID),调用方需走兼容路径。 +func SplitRefreshToken(token string) (randomPart string, sessionID int64, ok bool) { + idx := strings.LastIndex(token, ".") + if idx <= 0 || idx == len(token)-1 { + return "", 0, false + } + sid, err := strconv.ParseInt(token[idx+1:], 10, 64) + if err != nil || sid <= 0 { + return "", 0, false + } + return token[:idx], sid, true +} + +// HashRefreshTokenForCompare 计算用于与库中哈希比对的值: +// 新格式令牌哈希随机段,存量令牌哈希全文(历史版本的存储口径)。 +func HashRefreshTokenForCompare(token string) string { + if randomPart, _, ok := SplitRefreshToken(token); ok { + return HashRefreshToken(randomPart) + } + return HashRefreshToken(token) +} + // GenerateJti generates a new UUID-based JWT ID for session revocation tracking. func GenerateJti() string { return uuid.New().String() diff --git a/internal/logic/common/jwt_test.go b/internal/logic/common/jwt_test.go index f7e1f27..8349022 100644 --- a/internal/logic/common/jwt_test.go +++ b/internal/logic/common/jwt_test.go @@ -65,6 +65,65 @@ func TestGenerateRefreshToken_Unique(t *testing.T) { } } +// ─── BindSessionIDToRefreshToken / SplitRefreshToken ──────────────── + +func TestRefreshTokenSessionIDRoundTrip(t *testing.T) { + random, _ := GenerateRefreshToken() + bound := BindSessionIDToRefreshToken(random, 42) + + part, sid, ok := SplitRefreshToken(bound) + if !ok { + t.Fatalf("bound token should parse: %s", bound) + } + if part != random { + t.Fatalf("random part mismatch: %s vs %s", part, random) + } + if sid != 42 { + t.Fatalf("session id mismatch: %d vs 42", sid) + } +} + +func TestSplitRefreshToken_LegacyToken(t *testing.T) { + // 存量令牌为纯 hex 随机段,无 ".",必须走兼容路径(ok=false) + legacy, _ := GenerateRefreshToken() + if _, _, ok := SplitRefreshToken(legacy); ok { + t.Fatalf("legacy token without session id should not parse: %s", legacy) + } +} + +func TestSplitRefreshToken_Malformed(t *testing.T) { + cases := []string{ + "", // 空 + ".42", // 空随机段 + "abc.", // 空会话 ID + "abc.xid", // 非数字会话 ID + "abc.0", // 非正会话 ID + "abc.-1", // 负会话 ID + "deadbeef.deadbeef", // 随机段误含类数字后缀(hex 段被当作 ID 也应能解析或安全拒绝) + } + for _, c := range cases { + _, _, _ = SplitRefreshToken(c) // 只要不 panic 即可;具体 ok 值由格式决定 + } + // 关键安全约束:解析出的会话 ID 必须为正数,否则视为不可解析 + if _, sid, ok := SplitRefreshToken("abc.7"); !ok || sid != 7 { + t.Fatal("valid bound token failed to parse") + } +} + +func TestHashRefreshTokenForCompare(t *testing.T) { + random, _ := GenerateRefreshToken() + bound := BindSessionIDToRefreshToken(random, 7) + + // 新格式:比对哈希只算随机段(库中存储口径) + if got := HashRefreshTokenForCompare(bound); got != HashRefreshToken(random) { + t.Fatal("bound token should hash random part only") + } + // 存量格式:哈希全文 + if got := HashRefreshTokenForCompare(random); got != HashRefreshToken(random) { + t.Fatal("legacy token should hash full token") + } +} + // ─── GenerateTokenPair + ParseAccessToken round-trip ──────────────── func TestTokenPairRoundTrip(t *testing.T) { diff --git a/internal/logic/common/register_rate_limit.go b/internal/logic/common/register_rate_limit.go index f5322a8..816ff61 100644 --- a/internal/logic/common/register_rate_limit.go +++ b/internal/logic/common/register_rate_limit.go @@ -107,9 +107,19 @@ func incrementWithExpire(ctx context.Context, key string, expireSeconds int) (in return result.Int64(), nil } -// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示) -func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[string]any { - status := make(map[string]any) +// RegisterRateLimitStatus 当前IP的注册限流状态(注册页提示用) +type RegisterRateLimitStatus struct { + HourlyLimit int `json:"hourly_limit"` // 每小时注册上限(0=未启用) + HourlyRemaining int `json:"hourly_remaining"` // 本小时剩余次数 + HourlyResetSeconds int `json:"hourly_reset_seconds"` // 小时窗口重置剩余秒数(0=无计数记录) + DailyLimit int `json:"daily_limit"` // 每天注册上限(0=未启用) + DailyRemaining int `json:"daily_remaining"` // 今日剩余次数 + DailyResetSeconds int `json:"daily_reset_seconds"` // 天窗口重置剩余秒数(0=无计数记录) +} + +// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示,只读不计数) +func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) RegisterRateLimitStatus { + var status RegisterRateLimitStatus if ipAddress == "" { return status @@ -118,36 +128,30 @@ func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[strin // 查询IP每小时限制 hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour") if hourlyLimit > 0 { + status.HourlyLimit = hourlyLimit + status.HourlyRemaining = hourlyLimit hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress) hourlyCount, _ := g.Redis().Do(ctx, "GET", hourlyKey) if !hourlyCount.IsNil() { - remaining := hourlyLimit - hourlyCount.Int() - status["hourly_remaining"] = remaining - status["hourly_limit"] = hourlyLimit + status.HourlyRemaining = hourlyLimit - hourlyCount.Int() if ttl, err := g.Redis().Do(ctx, "TTL", hourlyKey); err == nil { - status["hourly_reset_seconds"] = ttl.Int() + status.HourlyResetSeconds = ttl.Int() } - } else { - status["hourly_remaining"] = hourlyLimit - status["hourly_limit"] = hourlyLimit } } // 查询IP每天限制 dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day") if dailyLimit > 0 { + status.DailyLimit = dailyLimit + status.DailyRemaining = dailyLimit dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress) dailyCount, _ := g.Redis().Do(ctx, "GET", dailyKey) if !dailyCount.IsNil() { - remaining := dailyLimit - dailyCount.Int() - status["daily_remaining"] = remaining - status["daily_limit"] = dailyLimit + status.DailyRemaining = dailyLimit - dailyCount.Int() if ttl, err := g.Redis().Do(ctx, "TTL", dailyKey); err == nil { - status["daily_reset_seconds"] = ttl.Int() + status.DailyResetSeconds = ttl.Int() } - } else { - status["daily_remaining"] = dailyLimit - status["daily_limit"] = dailyLimit } } diff --git a/internal/logic/common/register_rate_limit_test.go b/internal/logic/common/register_rate_limit_test.go index eaab68a..af24383 100644 --- a/internal/logic/common/register_rate_limit_test.go +++ b/internal/logic/common/register_rate_limit_test.go @@ -158,17 +158,23 @@ func TestGetRegisterRateLimitStatus(t *testing.T) { status := GetRegisterRateLimitStatus(ctx, testIP) // 验证状态 - if hourlyLimit, ok := status["hourly_limit"].(int); !ok || hourlyLimit != 5 { - t.Errorf("期望每小时限制为5,得到: %v", status["hourly_limit"]) + if status.HourlyLimit != 5 { + t.Errorf("期望每小时限制为5,得到: %d", status.HourlyLimit) } - if hourlyRemaining, ok := status["hourly_remaining"].(int); !ok || hourlyRemaining != 3 { - t.Errorf("期望每小时剩余3次,得到: %v", status["hourly_remaining"]) + if status.HourlyRemaining != 3 { + t.Errorf("期望每小时剩余3次,得到: %d", status.HourlyRemaining) } - if dailyLimit, ok := status["daily_limit"].(int); !ok || dailyLimit != 20 { - t.Errorf("期望每天限制为20,得到: %v", status["daily_limit"]) + if status.HourlyResetSeconds <= 0 { + t.Errorf("期望小时窗口重置秒数大于0,得到: %d", status.HourlyResetSeconds) } - if dailyRemaining, ok := status["daily_remaining"].(int); !ok || dailyRemaining != 18 { - t.Errorf("期望每天剩余18次,得到: %v", status["daily_remaining"]) + if status.DailyLimit != 20 { + t.Errorf("期望每天限制为20,得到: %d", status.DailyLimit) + } + if status.DailyRemaining != 18 { + t.Errorf("期望每天剩余18次,得到: %d", status.DailyRemaining) + } + if status.DailyResetSeconds <= 0 { + t.Errorf("期望天窗口重置秒数大于0,得到: %d", status.DailyResetSeconds) } // 清理测试数据 diff --git a/internal/logic/common/security.go b/internal/logic/common/security.go index db60e1e..bf8f2e3 100644 --- a/internal/logic/common/security.go +++ b/internal/logic/common/security.go @@ -418,6 +418,20 @@ func RegenerateBackupCodes(ctx context.Context, userType string, userID int64, c return plainCodes, nil } +// LoginLockoutPolicy 返回登录锁定策略(安全配置 sys_options:login_max_attempts / login_lockout_minutes)。 +// 未配置或非法值(<=0)时回退默认 5 次 / 30 分钟,管理后台与租户控制台共用同一策略。 +func LoginLockoutPolicy(ctx context.Context) (maxAttempts, lockoutMinutes int) { + maxAttempts = Config().GetInt(ctx, "login_max_attempts") + if maxAttempts <= 0 { + maxAttempts = 5 + } + lockoutMinutes = Config().GetInt(ctx, "login_lockout_minutes") + if lockoutMinutes <= 0 { + lockoutMinutes = 30 + } + return maxAttempts, lockoutMinutes +} + // Is2FAEnabled checks if 2FA is enabled for a user. func Is2FAEnabled(ctx context.Context, userType string, userID int64) (bool, error) { if userType == "admin" { diff --git a/internal/logic/common/session.go b/internal/logic/common/session.go index 437b15a..6866fb9 100644 --- a/internal/logic/common/session.go +++ b/internal/logic/common/session.go @@ -99,36 +99,6 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64, return sessionID, err } -// RefreshSession rotates a refresh token: invalidates the old one and creates a new session. -func RefreshSession(ctx context.Context, sessionID int64, oldRefreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo string) error { - refreshExpire := getRefreshExpire(ctx) - expiresAt := gtime.New(time.Now().Add(refreshExpire)) - - result, err := dao.SysSessions.Ctx(ctx). - Where("id", sessionID). - Where("refresh_token_hash", oldRefreshTokenHash). - Where("expires_at > NOW()"). - Data(do.SysSessions{ - RefreshTokenHash: newRefreshTokenHash, - IpAddress: ipAddress, - DeviceInfo: deviceInfo, - ExpiresAt: expiresAt, - }).Update() - if err != nil { - return gerror.Wrapf(err, "update session") - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return gerror.Wrapf(err, "check rows affected") - } - if rowsAffected == 0 { - return NewUnauthorizedError("会话不存在或已过期") - } - - return nil -} - // RevokeSession revokes a single session by ID (DB only; use MarkSessionRevoked for Redis). // userType 限定会话所属用户体系(admin/tenant),防止跨控制台按 ID 吊销他人会话。 func RevokeSession(ctx context.Context, userType string, sessionID int64) error { @@ -225,6 +195,111 @@ func GetSessionByID(ctx context.Context, userType string, sessionID int64) (*ent return session, nil } +// GetSessionByRefreshToken 按刷新令牌定位活跃会话,并检测"已轮换令牌重放"。 +// +// 新格式令牌内嵌会话 ID(random.sessionID):按 ID 定位会话后与随机段哈希比对, +// 命中即正常返回;会话仍在但哈希不匹配 = 该令牌已被轮换过 = 重放(典型场景: +// 令牌被盗、攻击者抢先刷新,受害者手里成了旧令牌)。重放直接吊销整个会话 +// (攻击者手中的新令牌一并作废,迫使用户重新登录)并写登录历史留痕。 +// 存量旧格式令牌(无会话 ID)退化为按哈希直查,行为与历史版本一致。 +// +// 返回 (session, replayed, err):replayed=true 时会话已在内部吊销,调用方返回 401 即可。 +func GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*entity.SysSessions, bool, error) { + if randomPart, sessionID, ok := SplitRefreshToken(refreshToken); ok { + var sess *entity.SysSessions + err := dao.SysSessions.Ctx(ctx). + Where("id", sessionID). + Where("expires_at > NOW()"). + Scan(&sess) + if err != nil { + return nil, false, err + } + if sess == nil { + // 会话已过期/被清理,按不存在拒绝,无重放可言 + return nil, false, nil + } + if sess.RefreshTokenHash == HashRefreshToken(randomPart) { + return sess, false, nil + } + + // 哈希不匹配 ≠ 重放:只有随机段哈希命中上一代令牌(轮换时记入 Redis) + // 才判定为重放。若仅凭"会话存在但哈希不匹配"就吊销,会话 ID 是顺序 + // bigint,任何人拼 {垃圾串}.{受害者会话ID} 即可恶意吊销他人会话(DoS)。 + if prev, rErr := g.Redis().Do(ctx, "GET", refreshPrevKey(sessionID)); rErr == nil && !prev.IsNil() && prev.String() == HashRefreshToken(randomPart) { + // 重放:吊销会话(Redis jti 黑名单 + 删会话行),留痕后拒绝 + MarkSessionRevoked(ctx, sess.Jti) + if _, delErr := dao.SysSessions.Ctx(ctx).Where("id", sessionID).Delete(); delErr != nil { + g.Log().Errorf(ctx, "revoke replayed session %d: %v", sessionID, delErr) + } + ip, ua := "", "" + if r := g.RequestFromCtx(ctx); r != nil { + ip = r.GetClientIp() + ua = r.GetHeader("User-Agent") + } + _ = RecordLoginHistory(ctx, sess.UserType, sess.UserId, sess.TenantId, "refresh", ip, ua, "", false, "刷新令牌重放,会话已吊销") + g.Log().Warningf(ctx, "refresh token replay detected: user_type=%s user_id=%d session=%d", sess.UserType, sess.UserId, sessionID) + return nil, true, nil + } + + // 既不是当前令牌也不是上一代:普通无效令牌,拒绝但不吊销 + return nil, false, nil + } + + // 存量旧格式令牌:按哈希直查(无法定位轮换来源,退化为普通拒绝) + sess, err := GetSessionByRefreshHash(ctx, HashRefreshTokenForCompare(refreshToken)) + return sess, false, err +} + +// refreshPrevKey 是上一代刷新令牌哈希的 Redis key(重放检测用,见 RotateSessionRefreshToken)。 +func refreshPrevKey(sessionID int64) string { + return fmt.Sprintf("session:refresh_prev:%d", sessionID) +} + +// RotateSessionRefreshToken 轮换会话刷新令牌:生成新随机段并原子替换库中哈希。 +// 返回绑定会话 ID 后的新令牌(返回给客户端的最终形态)。 +// UPDATE 同时校验旧哈希,并发下同一旧令牌只有一个请求能成功。 +func RotateSessionRefreshToken(ctx context.Context, sessionID int64, oldRefreshToken, ipAddress, deviceInfo string) (string, error) { + newRandom, err := GenerateRefreshToken() + if err != nil { + return "", err + } + oldHash := HashRefreshTokenForCompare(oldRefreshToken) + + refreshExpire := getRefreshExpire(ctx) + expiresAt := gtime.New(time.Now().Add(refreshExpire)) + + result, err := dao.SysSessions.Ctx(ctx). + Where("id", sessionID). + Where("refresh_token_hash", oldHash). + Where("expires_at > NOW()"). + Data(do.SysSessions{ + RefreshTokenHash: HashRefreshToken(newRandom), + IpAddress: ipAddress, + DeviceInfo: deviceInfo, + ExpiresAt: expiresAt, + }).Update() + if err != nil { + return "", gerror.Wrapf(err, "update session") + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return "", gerror.Wrapf(err, "check rows affected") + } + if rowsAffected == 0 { + return "", NewUnauthorizedError("会话不存在或已过期") + } + + // 记录上一代令牌哈希(TTL 对齐刷新周期):重放检测必须能证明"这个随机段 + // 曾经属于该会话",否则顺序会话 ID 会被用来恶意吊销他人会话。Redis 不可用 + // 时仅损失检测能力(降级为普通拒绝),不产生误吊销。 + if _, rErr := g.Redis().Do(ctx, "SETEX", refreshPrevKey(sessionID), 7*24*3600, oldHash); rErr != nil { + g.Log().Warningf(ctx, "record prev refresh hash for session %d: %v", sessionID, rErr) + } + + return BindSessionIDToRefreshToken(newRandom, sessionID), nil +} + // GetSessionByRefreshHash retrieves an active session by refresh token hash. func GetSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (*entity.SysSessions, error) { var session *entity.SysSessions @@ -264,6 +339,20 @@ func GetCtxUserID(ctx context.Context) int64 { return 0 } +// GetCtxUserRole extracts the admin privilege flag ("super_admin" / "admin") from context. +// 注意它是特权标记而非业务角色:业务角色存放在 sys_admin_user_roles, +// 这里只用于判断是否走超级管理员短路。 +func GetCtxUserRole(ctx context.Context) string { + val := ctx.Value("role") + if val == nil { + return "" + } + if role, ok := val.(string); ok { + return role + } + return "" +} + // GetCtxSessionID extracts session ID from context. func GetCtxSessionID(ctx context.Context) int64 { val := ctx.Value("sessionId") diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index f50620a..46e18e4 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -70,11 +70,13 @@ var Registry = []SettingDef{ {Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general", Label: "维护提示信息", Description: "维护模式下显示的提示文字", IsPublic: true}, {Key: "maintenance_duration", Type: SettingTypeString, Default: "", Category: "general", - Label: "预计维护时长", Description: "维护模式预计持续时间,展示给用户", IsPublic: true}, + Label: "预计维护时长", Description: "维护模式预计持续时间(Go 时长格式,如 2h、30m、1h30m),展示给用户", IsPublic: true}, {Key: "api_maintenance_enabled", Type: SettingTypeBool, Default: "false", Category: "general", Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true}, {Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general", Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"}, + {Key: "site_url", Type: SettingTypeString, Default: "", Category: "general", + Label: "站点后端地址", Description: "平台后端 API 的对外访问地址,如 https://api.example.com,用于构造 OAuth 登录回调地址(须与 GitHub/Google 应用后台登记的回调域名一致);为空时按当前请求 Host 推断"}, {Key: "pricing_time_timezone", Type: SettingTypeString, Default: "Asia/Shanghai", Category: "general", Label: "计费时区", Description: "模型时段定价(峰谷/促销)按此时区解释,IANA 时区名,如 Asia/Shanghai"}, @@ -113,16 +115,11 @@ var Registry = []SettingDef{ Label: "启用 TLS", Description: "587/465 端口建议开启"}, // ── Security ── - {Key: "max_sessions_per_user", Type: SettingTypeInt, Default: "10", Category: "security", - Label: "租户用户最大会话数", Validation: "min:1,max:100"}, - {Key: "admin_max_sessions", Type: SettingTypeInt, Default: "5", Category: "security", - Label: "管理员最大会话数", Validation: "min:1,max:50"}, + // 会话数上限不入注册表:并发会话限制由配置文件 jwt.adminMaxSessions / jwt.tenantMaxSessions 管理 {Key: "login_max_attempts", Type: SettingTypeInt, Default: "5", Category: "security", - Label: "登录最大尝试次数", Validation: "min:1,max:30"}, + Label: "登录最大尝试次数", Description: "连续密码错误达到该次数后锁定账号(管理后台 + 租户控制台)", Validation: "min:1,max:30"}, {Key: "login_lockout_minutes", Type: SettingTypeInt, Default: "30", Category: "security", - Label: "登录锁定时长(分钟)", Validation: "min:1,max:1440"}, - {Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security", - Label: "密码最小长度", Validation: "min:6,max:32"}, + Label: "登录锁定时长(分钟)", Description: "触发锁定后的持续时长(管理后台 + 租户控制台)", Validation: "min:1,max:1440"}, {Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security", Label: "启用 Turnstile 人机验证", IsPublic: true}, {Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security", @@ -131,23 +128,13 @@ var Registry = []SettingDef{ Label: "Turnstile Secret Key", Sensitive: true}, {Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security", Label: "验证码有效期(秒)", Validation: "min:60,max:600"}, - {Key: "new_device_notification", Type: SettingTypeBool, Default: "true", Category: "security", - Label: "新设备登录通知"}, {Key: "register_forbidden_words", Type: SettingTypeString, Default: "admin,system,root,api,test,administrator,管理员,系统", Category: "security", Label: "注册禁用词", Description: "组织名称、组织代码、用户名中包含这些词时禁止注册(不区分大小写),多个禁用词用英文逗号分隔"}, - {Key: "sandbox_enabled", Type: SettingTypeBool, Default: "true", Category: "performance", - Label: "启用沙箱模式"}, - {Key: "sandbox_default_quota", Type: SettingTypeInt, Default: "100", Category: "performance", - Label: "沙箱每月默认额度"}, // ── Audit ── {Key: "audit_level", Type: SettingTypeString, Default: "full", Category: "audit", Label: "全局审计级别", Validation: "enum:full,full_text,masked,question_only,none", Description: "full=完整记录, full_text=全量文本, masked=脱敏记录, question_only=仅提问, none=不记录"}, - {Key: "audit_retention_days", Type: SettingTypeInt, Default: "90", Category: "audit", - Label: "审计日志保留天数", Validation: "min:7,max:3650"}, - {Key: "operation_log_retention_days", Type: SettingTypeInt, Default: "365", Category: "audit", - Label: "操作日志保留天数", Validation: "min:30,max:3650"}, // ── Payment ── {Key: "payment_amount_options", Type: SettingTypeJSON, Default: "[10,20,50,100,200,500]", Category: "payment", @@ -174,32 +161,26 @@ var Registry = []SettingDef{ Label: "用户级 QPS 上限", Validation: "min:0"}, {Key: "key_qps_limit", Type: SettingTypeInt, Default: "60", Category: "performance", Label: "Key 级 QPS 上限", Validation: "min:0"}, + {Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance", + Label: "系统级并发上限", Validation: "min:0", + Description: "全平台同时在途请求的总闸门(多副本共享计数);流式长请求占用连接数分钟,QPS 限流拦不住堆积,须靠并发上限兜底;0=不限制,保存后约 5 秒生效"}, {Key: "tenant_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance", Label: "租户级并发上限", Validation: "min:0"}, - {Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "120", Category: "performance", - Label: "请求超时(秒)", Validation: "min:5,max:600"}, - {Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance", - Label: "批量写入大小", Validation: "min:1,max:1000"}, - {Key: "channel_affinity_ttl", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "渠道亲和性TTL(秒)", Validation: "min:0,max:3600"}, - {Key: "auto_test_interval", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "渠道自动测试间隔(秒)", Validation: "min:60,max:86400"}, + {Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "180", Category: "performance", + Label: "请求超时(秒)", Validation: "min:5,max:600", + Description: "非流式请求的全局默认总超时;渠道级超时设置优先,未配置的渠道使用此值,保存后即时生效"}, {Key: "streaming_timeout_seconds", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "流式超时(秒)", Validation: "min:30,max:3600", - Description: "流式响应最大持续时间"}, - {Key: "cache_enabled", Type: SettingTypeBool, Default: "true", Category: "performance", - Label: "缓存开关", Description: "是否启用 API Key 缓存"}, - {Key: "cache_ttl_seconds", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "缓存过期时间(秒)", Validation: "min:30,max:86400", - Description: "API Key 缓存有效期"}, + Label: "流式空闲超时(秒)", Validation: "min:30,max:3600", + Description: "流式响应两个数据块之间的最大空闲间隔(非总时长)的全局默认;渠道级设置优先,保存后即时生效"}, + {Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance", + Label: "批量写入大小", Validation: "min:1,max:1000", + Description: "审计日志批量落库条数,重启后生效"}, {Key: "batch_write_interval_ms", Type: SettingTypeInt, Default: "1000", Category: "performance", Label: "批量写入间隔(ms)", Validation: "min:100,max:30000", - Description: "日志批量写入的间隔时间"}, - {Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "1000", Category: "performance", - Label: "系统级并发上限", Validation: "min:0"}, + Description: "审计日志批量落库周期,重启后生效"}, {Key: "pprof_port", Type: SettingTypeInt, Default: "0", Category: "performance", Label: "pprof 端口", Validation: "min:0,max:65535", - Description: "性能分析端口,0=关闭"}, + Description: "性能分析端口,仅监听本机回环(127.0.0.1),0=关闭,重启后生效"}, // ── Content Filter ── {Key: "content_filter_mode", Type: SettingTypeString, Default: "off", Category: "content_filter", @@ -221,11 +202,11 @@ var Registry = []SettingDef{ {Key: "channel_auto_disable_enabled", Type: SettingTypeBool, Default: "false", Category: "channel", Label: "渠道自动禁用", Description: "渠道熔断持续超过路由策略 breaker.autoDisableAfterSeconds(默认 10 分钟)未恢复时自动禁用"}, {Key: "channel_routing_policy", Type: SettingTypeJSON, Default: "", Category: "channel", - Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。字段说明见 docs/reference/渠道调度运维手册.md"}, + Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。高级功能,非必要不要修改;字段说明见管理后台渠道配置页路由策略的说明弹窗"}, {Key: "health_snapshot_retention_days", Type: SettingTypeInt, Default: "7", Category: "channel", Label: "健康快照保留天数", Validation: "min:1,max:90"}, {Key: "channel_proxy_url", Type: SettingTypeString, Default: "", Category: "channel", - Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求"}, + Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求;OAuth 出站请求同样走此代理。保存后即时生效,无需重启"}, {Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "false", Category: "channel", Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持。依赖对象存储保存生成结果,开启前需先在「存储配置」中配置对象存储(OSS/S3/COS)"}, {Key: "sync_image_rehost_url", Type: SettingTypeBool, Default: "false", Category: "channel", @@ -264,8 +245,6 @@ var Registry = []SettingDef{ Label: "临时数据保留天数", Validation: "min:1,max:30"}, {Key: "data_export_expiry_days", Type: SettingTypeInt, Default: "7", Category: "data_governance", Label: "数据导出文件保留天数", Validation: "min:1,max:30"}, - {Key: "data_deletion_completion_days", Type: SettingTypeInt, Default: "30", Category: "data_governance", - Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"}, {Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance", Label: "启用文件保留期检查"}, {Key: "file_image_retention_days", Type: SettingTypeInt, Default: "0", Category: "data_governance", diff --git a/internal/logic/common/tenant_model.go b/internal/logic/common/tenant_model.go index a2f06b2..ee1c014 100644 --- a/internal/logic/common/tenant_model.go +++ b/internal/logic/common/tenant_model.go @@ -14,6 +14,7 @@ type AvailableModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -24,13 +25,14 @@ type AvailableModelItem struct { // GetTenantAvailableModels 计算租户实际可用的所有模型(显式分配 + 分组来源,去重) // 返回模型基本信息列表,不含价格。可被管理后台和租户控制台共用。 -func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, search string) ([]AvailableModelItem, error) { +func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, vendor, search string) ([]AvailableModelItem, error) { // 阶段1:获取显式分配的模型 var explicitModels []struct { ModelDBID int64 `json:"model_db_id"` ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -47,12 +49,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea if category != "" { explicitQuery = explicitQuery.Where("m.category", category) } + if vendor != "" { + explicitQuery = explicitQuery.Where("m.vendor", vendor) + } if search != "" { explicitQuery = explicitQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") } err := explicitQuery. - Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). OrderAsc("m.category"). OrderAsc("m.model_id"). Scan(&explicitModels) @@ -69,6 +74,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId: r.ModelId, ModelName: r.ModelName, Category: r.Category, + Vendor: r.Vendor, MaxContextTokens: r.MaxContextTokens, MaxOutputTokens: r.MaxOutputTokens, Description: r.Description, @@ -84,6 +90,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -110,12 +117,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea if category != "" { groupQuery = groupQuery.Where("m.category", category) } + if vendor != "" { + groupQuery = groupQuery.Where("m.vendor", vendor) + } if search != "" { groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") } _ = groupQuery. - Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). OrderAsc("m.category"). OrderAsc("m.model_id"). Scan(&groupModels) @@ -126,6 +136,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId: gm.ModelId, ModelName: gm.ModelName, Category: gm.Category, + Vendor: gm.Vendor, MaxContextTokens: gm.MaxContextTokens, MaxOutputTokens: gm.MaxOutputTokens, Description: gm.Description, diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index 3c43059..b15b56e 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -2,12 +2,14 @@ package common import ( "context" + "fmt" "strings" "time" "unicode" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/consts" ) @@ -111,6 +113,37 @@ func EndOfRange(value string) string { return value } +// UsageLogExportMaxWindowDays 用量日志类导出的时间窗上限(天),管理后台与租户控制台共用。 +const UsageLogExportMaxWindowDays = 31 + +// ValidateExportTimeWindow 校验导出必须限定时间窗:开始时间必填,结束时间缺省按 +// 「现在」计(与前端「截止留空 = 到现在」的口径一致),窗口不得超过 maxDays 天。 +// +// 列表接口天然有分页 LIMIT 兜底,导出没有 —— 不限窗口等于允许一次请求扫全量大表。 +// 更长跨度的取数应按窗口分多次导出。 +func ValidateExportTimeWindow(start, end string, maxDays int) error { + if start == "" { + return NewBadRequestError(fmt.Sprintf("导出必须指定开始时间,且时间跨度不超过 %d 天", maxDays)) + } + startAt := gtime.NewFromStr(StartOfRange(start)) + if startAt == nil { + return NewBadRequestError("开始时间格式不正确") + } + endAt := gtime.Now() + if end != "" { + if endAt = gtime.NewFromStr(EndOfRange(end)); endAt == nil { + return NewBadRequestError("结束时间格式不正确") + } + } + if endAt.Before(startAt) { + return NewBadRequestError("结束时间不能早于开始时间") + } + if endAt.Sub(startAt) > time.Duration(maxDays)*24*time.Hour { + return NewBadRequestError(fmt.Sprintf("导出时间跨度不能超过 %d 天,请缩小范围后分批导出", maxDays)) + } + return nil +} + // ValidateForbiddenWords 校验名称是否包含系统禁用词 // 从系统配置 register_forbidden_words 读取逗号分隔的禁用词列表,对 value 做大小写不敏感的包含检查 func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error { @@ -165,7 +198,7 @@ func ValidateTenantName(name string) error { } // TruncateToDisplayWidth 按显示宽度截断字符串,使结果宽度不超过 max。 -// 用于自动生成的组织名称兜底(如“xxx 的组织”),避免用户名过长导致存入超长数据。 +// 用于自动生成的组织名称兜底(默认取用户名),避免用户名过长导致存入超长数据。 func TruncateToDisplayWidth(s string, max int) string { width := 0 out := make([]rune, 0, len(s)) diff --git a/internal/logic/docs/openapi.go b/internal/logic/docs/openapi.go index f8c92a8..7f81153 100644 --- a/internal/logic/docs/openapi.go +++ b/internal/logic/docs/openapi.go @@ -202,6 +202,120 @@ func buildOpenAPISpec(ctx context.Context) map[string]any { }, }, }, + "/v1/videos": map[string]any{ + "post": map[string]any{ + "summary": "Create Video", + "description": "创建视频生成任务(OpenAI Videos 协议,兼容 openai-python / openai-node SDK 的 client.videos.create)。请求体支持 multipart/form-data(官方 SDK 形态,input_reference 可为文件 part)与 application/json 两种编码。返回官方 Video 对象(status: queued),轮询 GET /v1/videos/{video_id} 直到 completed,再经 GET /v1/videos/{video_id}/content 获取视频二进制。", + "operationId": "createVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "multipart/form-data": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"model", "prompt"}, + "properties": map[string]any{ + "model": map[string]any{"type": "string", "example": "kling-v2-master"}, + "prompt": map[string]any{"type": "string"}, + "seconds": map[string]any{"type": "string", "enum": []string{"4", "8", "12"}, "default": "4"}, + "size": map[string]any{"type": "string", "enum": []string{"720x1280", "1280x720", "1024x1792", "1792x1024"}, "default": "720x1280"}, + "aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1;取值原样透传上游,不传由模型取默认档"}, + "sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音;仅部分模型支持(可灵 / 豆包),其余模型忽略"}, + "input_reference": map[string]any{"type": "string", "format": "binary", "description": "可选参考图(图生视频),文件上传或 {\"image_url\": \"...\"} 引用对象;file_id 形态不支持"}, + }, + }, + }, + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"model", "prompt"}, + "properties": map[string]any{ + "model": map[string]any{"type": "string"}, + "prompt": map[string]any{"type": "string"}, + "seconds": map[string]any{"type": "string"}, + "size": map[string]any{"type": "string"}, + "aspect_ratio": map[string]any{"type": "string", "description": "扩展字段:屏幕比例,如 16:9 / 9:16 / 1:1"}, + "sound": map[string]any{"type": "string", "enum": []string{"on", "off"}, "description": "扩展字段:是否包含声音(也可用布尔形态 generate_audio)"}, + "generate_audio": map[string]any{"type": "boolean", "description": "扩展字段:sound 的等价布尔形态,sound 缺省时生效"}, + "input_reference": map[string]any{"type": "object", "properties": map[string]any{"image_url": map[string]any{"type": "string"}}}, + }, + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "Video 对象(object: video,status: queued)"}, + }, + }, + }, + "/v1/videos/{video_id}": map[string]any{ + "get": map[string]any{ + "summary": "Retrieve Video", + "description": "查询视频生成任务,返回官方 Video 对象。status: queued / in_progress / completed / failed;非终态响应携带 openai-poll-after-ms 轮询间隔建议头。生成失败以 status=failed + error 对象表达(HTTP 仍为 200)。", + "operationId": "retrieveVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "Video 对象(含 progress 百分比、completed_at、失败时的 error)"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + }, + }, + "delete": map[string]any{ + "summary": "Delete Video", + "description": "删除已完成或已失败的视频任务(软删除,计费与审计记录保留),删除后查询与下载均返回 404。进行中的任务不可删除。", + "operationId": "deleteVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "删除确认(object: video.deleted,deleted: true)"}, + "400": map[string]any{"description": "任务非终态或计费未结算,不可删除"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + }, + }, + }, + "/v1/videos/{video_id}/content": map[string]any{ + "get": map[string]any{ + "summary": "Download Video Content", + "description": "下载已生成完成的视频二进制流(mp4)。仅 status=completed 的任务可下载;variant 仅支持默认 video(thumbnail / spritesheet 暂不支持)。", + "operationId": "downloadVideoContent", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + { + "name": "variant", + "in": "query", + "required": false, + "schema": map[string]any{"type": "string", "enum": []string{"video"}, "default": "video"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "视频二进制流(video/mp4)"}, + "400": map[string]any{"description": "任务未完成或无可用内容"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + "502": map[string]any{"description": "上游内容拉取失败"}, + }, + }, + }, "/v1/messages": map[string]any{ "post": map[string]any{ "summary": "Claude Messages", diff --git a/internal/logic/monitor/relaykit_metrics.go b/internal/logic/monitor/relaykit_metrics.go index 166f975..12eb433 100644 --- a/internal/logic/monitor/relaykit_metrics.go +++ b/internal/logic/monitor/relaykit_metrics.go @@ -25,25 +25,25 @@ import ( // RelayConverterMetrics 是单个转换器的累计指标快照(读取时计算派生字段)。 type RelayConverterMetrics struct { - ConverterID string `json:"converter_id"` // 如 "openai_to_claude" - From string `json:"from"` // 源协议格式 - To string `json:"to"` // 目标协议格式 - Success int64 `json:"success"` // 累计成功次数 - Failed int64 `json:"failed"` // 累计失败次数 - TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值 - LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断) - ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed) - AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒) + ConverterID string `json:"converter_id"` // 如 "openai_to_claude" + From string `json:"from"` // 源协议格式 + To string `json:"to"` // 目标协议格式 + Success int64 `json:"success"` // 累计成功次数 + Failed int64 `json:"failed"` // 累计失败次数 + TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值 + LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断) + ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed) + AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒) } // converterCounter 是单个转换器的并发安全计数器。 // from/to 在创建时写入后只读(无需加锁);热路径字段用 atomic;lastErr 失败稀疏,用自带互斥锁。 type converterCounter struct { - from string - to string - success atomic.Int64 - failed atomic.Int64 - totalMs atomic.Int64 + from string + to string + success atomic.Int64 + failed atomic.Int64 + totalMs atomic.Int64 lastErrMu sync.Mutex lastErr string } 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/logic/open/open_api.go b/internal/logic/open/open_api.go index bbf5d1a..6714abe 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -101,12 +101,12 @@ func (s *sOpen) OpenMemberList(ctx context.Context, req *v1.OpenMemberListReq) ( } var users []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + Id int64 `json:"id" orm:"id"` + Username string `json:"username" orm:"username"` + Email string `json:"email" orm:"email"` + Role string `json:"role" orm:"role"` + Status string `json:"status" orm:"status"` + CreatedAt string `json:"created_at" orm:"created_at"` } err = m.OrderDesc("id").Page(page, pageSize).Scan(&users) if err != nil { @@ -137,6 +137,15 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe return nil, err } + // 团队开关与席位上限:控制台的 InviteMember 两者都校验,开放平台此前一项都没有, + // 于是买 10 个席位的租户可以从这里建出任意多成员,套餐限制被整条绕开。 + if err := requireOpenTeamEnabled(ctx, tenantID); err != nil { + return nil, err + } + if err := requireOpenMemberSeat(ctx, tenantID); err != nil { + return nil, err + } + count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count() if count > 0 { return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在") @@ -173,17 +182,44 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe return nil, err } + // 必须先取目标成员的角色:同文件的 OpenMemberDelete 挡了 owner,这里此前没挡。 + // 请求校验 v:"in:admin,member" 只拦住了「把别人提升为 owner」,没拦住「把 owner 降为 + // member」或「把 owner 置为 disabled」—— 而租户 admin 本就能建带 members:write 的应用, + // 于是 admin 可以经由开放平台把组织所有者踢出去,控制台的四处 owner 保护全被绕过。 + var target *struct { + Role string `json:"role"` + } + err := dao.TntUsers.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Fields("role"). + Scan(&target) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if target == nil { + return nil, errOpen(consts.CodeNotFound, "用户不存在") + } + data := do.TntUsers{} hasUpdate := false + roleChanged := false if req.Role != nil { + if target.Role == "owner" { + return nil, errOpen(consts.CodeForbidden, "不能修改所有者的角色") + } data.Role = *req.Role hasUpdate = true + roleChanged = *req.Role != target.Role } if req.DisplayName != nil { data.DisplayName = *req.DisplayName hasUpdate = true } if req.Status != nil { + if target.Role == "owner" && *req.Status != "active" { + return nil, errOpen(consts.CodeForbidden, "不能禁用组织所有者") + } data.Status = *req.Status hasUpdate = true } @@ -192,8 +228,19 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe return nil, nil } - _, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update() - return nil, err + if _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update(); err != nil { + return nil, err + } + + // 与控制台 UpdateMemberRole 对齐:角色变更或停用后撤销该成员的活跃会话。 + // 租户角色本就每请求从库重读,这一步是纵深防御 —— 失败只记日志,不回滚已生效的变更。 + if roleChanged || (req.Status != nil && *req.Status != "active") { + if revokeErr := common.RevokeAllSessions(ctx, "tenant", req.Id); revokeErr != nil { + g.Log().Warningf(ctx, "[OpenMemberUpdate] 撤销成员会话失败 user=%d: %v", req.Id, revokeErr) + } + } + + return nil, nil } func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) { @@ -307,7 +354,7 @@ func (s *sOpen) OpenMemberModels(ctx context.Context, req *v1.OpenMemberModelsRe } var scopes []struct { - ModelID string `json:"model_id"` + ModelID string `json:"model_id" orm:"model_id"` } err := dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). @@ -413,11 +460,11 @@ func (s *sOpen) OpenKeyList(ctx context.Context, req *v1.OpenKeyListReq) (*v1.Op } var keys []struct { - Id int64 `json:"id"` - Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` - Status string `json:"status"` - CreatedAt *gtime.Time `json:"created_at"` + Id int64 `json:"id" orm:"id"` + Name string `json:"name" orm:"name"` + KeyPrefix string `json:"key_prefix" orm:"key_prefix"` + Status string `json:"status" orm:"status"` + CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` } err = m.OrderDesc("id").Page(page, pageSize).Scan(&keys) if err != nil { @@ -449,6 +496,30 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v return nil, err } + // 权限范围此前被硬编码为 "full" —— 开放平台签发的每一把 Key 都是最大权限, + // 应用方连「只发一把 chat_only 的 Key」都做不到。改为按请求取值(默认仍是 full, + // 保持既有调用方行为不变),取值集合与 OpenProjectKeyCreate 对齐。 + scope := req.Scope + if scope == "" { + scope = "full" + } + + // project_id 可选,填写时必须是本租户下的活跃项目。 + // relay 的项目状态闸门是 `if k.ProjectID > 0`,只有真正写入 project_id,项目归档/停用 + // 才会连带吊销这把 Key;此前这里恒不写 project_id,签出的 Key 完全不受项目生命周期约束。 + // 不填则是租户级集成密钥(无项目、无成员归属),仍按 key_type=project 存储 —— 控制台 + // 的项目密钥列表正是按 key_type 过滤,改成 personal 反而会因缺少 user_id 而在界面上 + // 彻底消失、无法吊销。 + if req.ProjectID > 0 { + project, perr := s.getProjectOrError(ctx, req.ProjectID, tenantID) + if perr != nil { + return nil, perr + } + if project.Status != "active" { + return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用") + } + } + rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx) if err != nil { return nil, err @@ -460,16 +531,21 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v } quotaLimitDecimal := billing.NewFromFloat(quotaLimit) - result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{ + insertData := do.ApiKeys{ TenantId: tenantID, Name: req.Name, EncryptedKey: encryptedKey, KeyPrefix: prefix, - Scope: "full", + Scope: scope, Status: "active", TotalQuota: "aLimitDecimal, KeyType: "project", - }).Insert() + } + if req.ProjectID > 0 { + insertData.ProjectId = req.ProjectID + } + + result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert() if err != nil { return nil, err } @@ -688,6 +764,41 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil } +// requireOpenTeamEnabled 校验租户已启用团队功能。 +// 与 internal/logic/tenant 的 requireTeamEnabled 同义;那个是包内私有函数, +// 此处不便跨包复用(tenant 包体量大且反向依赖会形成环),故按同一判据本地实现。 +func requireOpenTeamEnabled(ctx context.Context, tenantID int64) error { + enabled, err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID).Fields("team_enabled").Value() + if err != nil { + return err + } + if !enabled.Bool() { + return errOpen(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled) + } + return nil +} + +// requireOpenMemberSeat 校验租户仍有可用席位,判据与控制台 InviteMember 完全一致: +// 活跃成员数 < 生效上限(上限为 0 表示不限)。 +func requireOpenMemberSeat(ctx context.Context, tenantID int64) error { + memberCount, err := dao.TntUsers.Ctx(ctx). + Where("tenant_id", tenantID). + Where("status", "active"). + Count() + if err != nil { + return err + } + effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenantID) + if err != nil { + return gerror.Wrapf(err, "查询租户限制信息失败") + } + if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers { + return errOpen(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached) + } + return nil +} + // errOpen is a helper to create open platform errors with proper gerror codes. func errOpen(code int, message string) error { return gerror.NewCode(gcode.New(code, message, nil), message) diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 6f0485b..66c9d1d 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -178,10 +178,14 @@ func checkGroupModelAccess(ctx context.Context, tenantID int64, modelName string } } - // 缓存未命中:查询数据库 - groupCount, _ := dao.MdlTenantGroups.Ctx(ctx). + // 缓存未命中:查询数据库。错误必须上抛:若把查询失败当成 count=0 并缓存"无分组"负结果, + // 一次瞬断会让后续 300s(TTL)内所有模型权限校验和 /v1/models 都把该租户当成无分组 + groupCount, err := dao.MdlTenantGroups.Ctx(ctx). Where("tenant_id", tenantID). Count() + if err != nil { + return false, nil, err + } if groupCount == 0 { lcommon.TenantGroupModelCache.Set(ctx, cacheKey, nil) @@ -226,19 +230,19 @@ func queryTenantGroupModels(ctx context.Context, tenantID int64) (map[string]boo return result, nil } -// GetTenantGroupModelIDs 获取租户通过分组可访问的模型内部ID集合(用于 GetAvailableModels) -func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) map[int64]bool { +// GetTenantGroupModelIDs 获取租户通过分组可访问的模型内部ID集合(用于 GetAvailableModels)。 +// DB 查询错误原样上抛:调用方必须区分"无分组"(nil, nil)与"查询失败"(err), +// 吞错会让数据库瞬断被当成无分配,/v1/models 静默返回空列表。 +func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) (map[int64]bool, error) { cacheKey := fmt.Sprintf("%d", tenantID) - // 尝试从缓存获取 model_id string 集合 + // 缓存 nil 表示该租户无任何分组(checkGroupModelAccess 写入的负结果),直接短路 cachedSet, found := lcommon.TenantGroupModelCache.Get(ctx, cacheKey) - if found { - if cachedSet == nil { - return nil - } - // 缓存中存的是 map[string]bool,需要转换为 map[int64]bool - // 这里直接查数据库获取 int64 版本 + if found && cachedSet == nil { + return nil, nil } + // 正结果缓存存的是模型名集合(map[string]bool),与本函数的内部 ID 集合类型不同, + // 无法直接复用,仍需查库 // 查询模型内部 ID var models []struct { @@ -253,15 +257,18 @@ func GetTenantGroupModelIDs(ctx context.Context, tenantID int64) map[int64]bool Where("m.status", "active"). Fields("m.id"). Scan(&models) - if err != nil || len(models) == 0 { - return nil + if err != nil { + return nil, err + } + if len(models) == 0 { + return nil, nil } result := make(map[int64]bool, len(models)) for _, m := range models { result[m.Id] = true } - return result + return result, nil } // GetModelMapping 实现 DataProvider.GetModelMapping @@ -404,10 +411,13 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs { AudioOutputTokens: record.AudioOutputTokens, ImageOutputTokens: record.ImageOutputTokens, ReasoningTokens: record.ReasoningTokens, + DurationSeconds: record.DurationSeconds, RequestedModel: sanitizeUTF8(record.RequestedModel), UpstreamModel: sanitizeUTF8(record.UpstreamModel), + UpstreamRequestId: sanitizeUTF8(record.UpstreamRequestID), + RequestType: requestType, UserAgent: sanitizeUTF8(record.UserAgent), FirstTokenMs: record.FirstTokenMs, @@ -505,6 +515,13 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit } } + // 模型名超长会导致 INSERT 失败、整条审计丢失:按列宽 100 字节截断, + // 截断可能切碎多字节字符,需再清洗一次保证 UTF-8 合法 + modelName := sanitizeUTF8(record.Model) + if len(modelName) > 100 { + modelName = sanitizeUTF8(modelName[:100]) + } + insertData := do.AudRequestLogs{ TenantId: record.TenantID, UserId: record.UserID, @@ -517,6 +534,7 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit StatusCode: record.StatusCode, ClientIp: record.ClientIP, UserAgent: record.UserAgent, + ModelName: modelName, RequestBody: sysReq, ResponseBody: sysResp, TenantRequestBody: tntReq, @@ -718,18 +736,24 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api } var models []modelRow - var err error fieldsNoAlias := "model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities" if tenantID > 0 { - // 获取显式分配的模型 - tenantModelCount, _ := dao.MdlTenantModels.Ctx(ctx). + // 获取显式分配的模型。DB 错误必须上抛:此处吞错会把"查询失败"当成"无分配", + // 数据库瞬断时 /v1/models 会静默返回空列表(200)且无任何日志 + tenantModelCount, err := dao.MdlTenantModels.Ctx(ctx). Where("tenant_id", tenantID). Count() + if err != nil { + return nil, err + } - // 获取分组分配的模型 - groupModelIDs := GetTenantGroupModelIDs(ctx, tenantID) + // 获取分组分配的模型(错误同样上抛,理由同上) + groupModelIDs, err := GetTenantGroupModelIDs(ctx, tenantID) + if err != nil { + return nil, err + } hasExplicit := tenantModelCount > 0 hasGroup := len(groupModelIDs) > 0 @@ -748,7 +772,7 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api if err := dao.MdlTenantModels.Ctx(ctx). Where("tenant_id", tenantID).Where("enabled", true). Fields("model_id").Scan(&explicitIDs); err != nil { - g.Log().Warningf(ctx, "[DataProvider] GetAvailableModels scan tenant models failed: tenantID=%d, err=%v", tenantID, err) + return nil, err } for _, id := range explicitIDs { allModelIDs = append(allModelIDs, id.ModelID) @@ -760,27 +784,27 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api } if len(allModelIDs) > 0 { - err = dao.MdlModels.Ctx(ctx). + if err := dao.MdlModels.Ctx(ctx). WhereIn("id", allModelIDs). Where("status", "active"). Fields(fieldsNoAlias). OrderAsc("category"). OrderAsc("model_id"). - Scan(&models) + Scan(&models); err != nil { + return nil, err + } } } } else { // tenantID == 0:返回所有活跃模型(公开端点场景) - err = dao.MdlModels.Ctx(ctx). + if err := dao.MdlModels.Ctx(ctx). Where("status", "active"). Fields(fieldsNoAlias). OrderAsc("category"). OrderAsc("model_id"). - Scan(&models) - } - - if err != nil { - return nil, err + Scan(&models); err != nil { + return nil, err + } } // 按 API Key 的模型范围过滤 @@ -788,10 +812,12 @@ func (p *DataProviderImpl) GetAvailableModels(ctx context.Context, tenantID, api var keyScopes []struct { ModelName string `json:"model_name"` } - _ = dao.ApiKeyModelScopes.Ctx(ctx). + if err := dao.ApiKeyModelScopes.Ctx(ctx). Where("api_key_id", apiKeyID). Fields("model_name"). - Scan(&keyScopes) + Scan(&keyScopes); err != nil { + return nil, err + } if len(keyScopes) > 0 { allowed := make(map[string]bool, len(keyScopes)) @@ -1285,10 +1311,18 @@ func ClearTenantModelAccessCache(ctx context.Context, tenantID int64) { } // memberModelCache 成员模型范围缓存(TTL 60s)。 -// 这是该缓存唯一的生产者(Get/Set),tenant 与 open 业务包通过 -// InvalidateMemberModelScopeCache 显式失效本缓存(写操作后调用), -// 共享 "member_model" 前缀是故意的跨包失效机制,并非命名冲突—— -// 详见 InvalidateMemberModelScopeCache。 +// tenant 与 open 业务包通过 InvalidateMemberModelScopeCache 显式失效本缓存(写操作后调用), +// 共享 "member_model" 前缀是故意的跨包失效机制,并非命名冲突——详见 InvalidateMemberModelScopeCache。 +// +// 本包内有且仅有两个写者:GetMemberAllowedModelNames 与 CheckMemberModelAccess,二者共用 +// 同一 key({tenantID}:{userID}),必须遵循统一编码,修改任一侧前务必同步另一侧: +// - [] = 无范围记录(不限制) +// - [__none__] = 全部禁止(tnt_member_model_scopes.model_id = -1 哨兵) +// +// 历史上两写者编码不一致(列表路径曾用 [] 表示全禁),导致 chat 请求后 60s 内 +// /v1/models 间歇性返回空列表、全禁成员反而被放行,2026-09 已统一。 +const memberScopeNoneSentinel = "__none__" + var memberModelCache = lcommon.NewCache("member_model", 60*time.Second) // InvalidateMemberModelScopeCache 失效指定成员的模型范围缓存。 @@ -1309,7 +1343,8 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([] var cachedNames []string if memberModelCache.GetJSON(ctx, cacheKey, &cachedNames) { - return cachedNames, nil + // 缓存可能由 CheckMemberModelAccess 写入,必须走统一编码解码(见 memberScopeNoneSentinel 注释) + return decodeMemberScopeNames(cachedNames), nil } type scopeRow struct { @@ -1334,7 +1369,7 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([] names := make([]string, 0, len(rows)) for _, r := range rows { if r.ModelID == -1 { - memberModelCache.Set(ctx, cacheKey, []string{}) + memberModelCache.Set(ctx, cacheKey, []string{memberScopeNoneSentinel}) return []string{}, nil } if r.ModelName != "" { @@ -1342,10 +1377,32 @@ func GetMemberAllowedModelNames(ctx context.Context, tenantID, userID int64) ([] } } + // scope 记录存在但关联模型已全部从目录删除:fail-closed 按全禁处理。 + // 注意不能写空切片——统一编码里 [] 表示"无记录不限" + if len(names) == 0 { + memberModelCache.Set(ctx, cacheKey, []string{memberScopeNoneSentinel}) + return []string{}, nil + } + memberModelCache.Set(ctx, cacheKey, names) return names, nil } +// decodeMemberScopeNames 解码 memberModelCache 中的成员模型范围(与 CheckMemberModelAccess +// 编码对齐,见 memberScopeNoneSentinel 注释): +// 含 __none__ 哨兵 → 全部禁止(空非 nil 切片);[] = 无范围记录(nil,不限制);其余原样返回 +func decodeMemberScopeNames(cached []string) []string { + for _, n := range cached { + if n == memberScopeNoneSentinel { + return []string{} + } + } + if len(cached) == 0 { + return nil + } + return cached +} + // CheckMemberModelAccess 检查成员是否有权使用指定模型。 // 无 scope 记录表示不限制(向后兼容)。 // 哨兵 model_id=-1 表示该成员被限制使用所有模型。 @@ -1359,7 +1416,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, } // 哨兵值:表示该成员被限制使用所有模型 for _, name := range cachedModelNames { - if name == "__none__" { + if name == memberScopeNoneSentinel { return false, nil } } @@ -1389,7 +1446,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, names := make([]string, 0, len(rows)) for _, r := range rows { if r.ModelID == -1 { - names = append(names, "__none__") + names = append(names, memberScopeNoneSentinel) } else if r.ModelName != "" { names = append(names, r.ModelName) } @@ -1402,7 +1459,7 @@ func (p *DataProviderImpl) CheckMemberModelAccess(ctx context.Context, tenantID, } // 哨兵值:表示该成员被限制使用所有模型 for _, name := range names { - if name == "__none__" { + if name == memberScopeNoneSentinel { return false, nil } } diff --git a/internal/logic/relay/provider_test.go b/internal/logic/relay/provider_test.go index db2a274..d075ed3 100644 --- a/internal/logic/relay/provider_test.go +++ b/internal/logic/relay/provider_test.go @@ -67,6 +67,42 @@ func TestSafeUTF8Truncate(t *testing.T) { } } +// TestDecodeMemberScopeNames 验证成员模型范围缓存的统一编码解码 +// (__none__ 哨兵 = 全禁;空数组 = 无范围记录不限制;白名单原样返回)。 +// 该编码被 GetMemberAllowedModelNames / CheckMemberModelAccess 两个写者共用, +// 解码错误会直接导致 /v1/models 间歇性返回空列表,回归必测。 +func TestDecodeMemberScopeNames(t *testing.T) { + tests := []struct { + name string + cached []string + want []string + wantN bool // 期望返回 nil(不限制) + }{ + {"全禁哨兵解码为空非nil切片", []string{memberScopeNoneSentinel}, []string{}, false}, + {"脏数据哨兵混入白名单仍全禁", []string{"gpt-4o", memberScopeNoneSentinel}, []string{}, false}, + {"空数组表示无记录不限制", []string{}, nil, true}, + {"nil表示无记录不限制", nil, nil, true}, + {"白名单原样返回", []string{"gpt-4o", "claude-3-5-sonnet"}, []string{"gpt-4o", "claude-3-5-sonnet"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := decodeMemberScopeNames(tt.cached) + if tt.wantN { + if got != nil { + t.Fatalf("期望 nil(不限制),实际 %v", got) + } + return + } + if got == nil { + t.Fatalf("期望非 nil %v,实际 nil", tt.want) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("期望 %v,实际 %v", tt.want, got) + } + }) + } +} + func TestTruncateBody_ShortReturnedVerbatim(t *testing.T) { s := "short body" if got := truncateBody(s, 100); got != s { diff --git a/internal/logic/settings/settings.go b/internal/logic/settings/settings.go index 071ead7..186422e 100644 --- a/internal/logic/settings/settings.go +++ b/internal/logic/settings/settings.go @@ -65,13 +65,13 @@ func (s *sSettings) PublicAnnouncements(ctx context.Context, req *v1.PublicAnnou } var items []struct { - Id int64 `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Content string `json:"content"` - IsPinned int `json:"is_pinned"` - DisplayPosition string `json:"display_position"` - CreatedAt string `json:"created_at"` + Id int64 `json:"id" orm:"id"` + Title string `json:"title" orm:"title"` + Type string `json:"type" orm:"type"` + Content string `json:"content" orm:"content"` + IsPinned int `json:"is_pinned" orm:"is_pinned"` + DisplayPosition string `json:"display_position" orm:"display_position"` + CreatedAt string `json:"created_at" orm:"created_at"` } err := m.OrderDesc("is_pinned").OrderDesc("created_at"). Limit(20). diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index c2a0e53..b398431 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -326,12 +326,13 @@ func processChannelTasks(ctx context.Context, channelID int64, tasks []*common.A // privateData PrivateData 反序列化结构 type privateData struct { - UpstreamTaskID string `json:"upstream_task_id"` - TaskType string `json:"task_type"` - BillingContext struct { - Ratios map[string]float64 `json:"ratios"` - ModelName string `json:"model_name"` - PreDeduct float64 `json:"pre_deduct"` + UpstreamTaskID string `json:"upstream_task_id"` + UpstreamRequestID string `json:"upstream_request_id"` // 上游提交调用追踪 ID(部分上游返回,可空) + TaskType string `json:"task_type"` + BillingContext struct { + Ratios map[string]any `json:"ratios"` + ModelName string `json:"model_name"` + PreDeduct float64 `json:"pre_deduct"` } `json:"billing_context"` } @@ -344,15 +345,44 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co return } - // 查询上游状态 + // 查询上游状态(model 供供应商适配器按模型分派协议端点,如 MiniMax v1/v2; + // 其余适配器按结构体解析 taskData,自动忽略未知键) + upstreamModel := task.UpstreamModel + if upstreamModel == "" { + upstreamModel = task.ModelName + } taskData, _ := json.Marshal(map[string]any{ "task_id": pd.UpstreamTaskID, "use_proxy": parseChannelUseProxy(channel.Settings), + "model": upstreamModel, }) - resp, err := adaptor.FetchTask(channel.BaseURL, channel.ApiKey, taskData) + // 渠道调试日志(轮询记录,仅段2/段3——后台轮询无客户端交互): + // 节流规则 = 只在「状态/进度相对上一次轮询发生变化」时落库(任务行 CAS 前的值即上一次 + // 持久化状态,无需额外存储),防止 15s 轮询周期灌爆 chn_debug_logs; + // fetch/parse 错误无「上一次」可对比且是轮询排查的主要目标,必记。 + // request_id 复用提交请求原 ID,管理后台按 request_id 筛选可看到提交 + 全部状态转移。 + var dbgSess *common.DebugSession + var dbgAttempt *common.DebugAttempt + if pollDebugLogEnabled(channel.Settings, task) { + dbgSess = common.NewDebugSession(task.RequestID, task.TenantID, task.UserID, task.ApiKeyID, "task_poll:"+task.PublicTaskID) + dbgAttempt = dbgSess.BeginTaskAttempt(&common.ChannelMeta{ + ChannelID: channel.ID, + ChannelName: channel.Name, + ChannelType: channel.Type, + UpstreamModelName: upstreamModel, + }, task.ModelName, task.Platform, 0) + } + pollCtx := ctx + if dbgAttempt != nil { + pollCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture) + } + + resp, err := adaptor.FetchTask(pollCtx, channel.BaseURL, channel.ApiKey, taskData) if err != nil { g.Log().Warningf(ctx, "poll: fetch task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } defer resp.Body.Close() @@ -360,6 +390,8 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co body, err := io.ReadAll(resp.Body) if err != nil { g.Log().Warningf(ctx, "poll: read response for task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } @@ -367,9 +399,17 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co taskInfo, err := adaptor.ParseTaskResult(body) if err != nil { g.Log().Warningf(ctx, "poll: parse result for task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } + // 状态变化判定 + 调试记录提交(CAS 更新 task.Status 之前,task 行内即上一次轮询值) + if pollStateChanged(taskInfo, task) { + dbgAttempt.MarkFinal(nil) + dbgSess.FinalizeAndSubmit(0, 0) + } + // CAS 更新状态 oldStatus := task.Status task.Status = string(taskInfo.Status) @@ -421,6 +461,14 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co // 优先用上游返回的 ActualCost if taskInfo.ActualCost > 0 { actualCost = billing.NewFromFloat(taskInfo.ActualCost) + } else if taskInfo.MaterialUsage != nil { + // 素材计费方案:按官方 usage 计量(输入视频时长/图片张数/输出秒数)重算。 + // 链接素材的时长提交时不可知,此为最终计费依据;模型未配置素材方案时 ok=false 保持预扣 + if materialCost, ok, mErr := taskBilling.RecalculateByMaterials(ctx, task.TenantID, task.ModelName, pd.BillingContext.Ratios, taskInfo.MaterialUsage, task.CreatedAt); mErr != nil { + g.Log().Warningf(ctx, "poll: recalculate by materials task %s: %v", task.PublicTaskID, mErr) + } else if ok { + actualCost = materialCost + } } else if taskInfo.TotalTokens > 0 && pd.BillingContext.Ratios != nil { // 用上游 total_tokens + 保存的 ratios 重算 if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios, task.CreatedAt); err == nil && tokenCost.GreaterThan(billing.Zero) { @@ -492,6 +540,32 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s status = "error" } + // 按秒计费任务的视频时长:取提交时计费上下文里的 spec.duration(真实秒数)。 + // 只认 spec.* 事实键——旧 duration 键在部分 adaptor 是乘数语义(如 gemini 1.3),不可作为时长 + // 提取上游请求 ID 落用量日志(排障时凭它在上游定位任务): + // 优先 upstream_request_id(上游调用追踪 ID,如 DashScope 顶层 request_id), + // 未记录时回退 upstream_task_id(任务句柄,轮询同款标识) + durationSeconds := 0 + upstreamRequestID := "" + if len(task.PrivateData) > 0 { + var pdDur struct { + UpstreamTaskID string `json:"upstream_task_id"` + UpstreamRequestID string `json:"upstream_request_id"` + BillingContext struct { + Ratios map[string]any `json:"ratios"` + } `json:"billing_context"` + } + if json.Unmarshal(task.PrivateData, &pdDur) == nil { + upstreamRequestID = pdDur.UpstreamRequestID + if upstreamRequestID == "" { + upstreamRequestID = pdDur.UpstreamTaskID + } + if v, ok := pdDur.BillingContext.Ratios["spec.duration"].(float64); ok && v > 0 { + durationSeconds = int(v) + } + } + } + record := &common.UsageRecord{ TenantID: task.TenantID, UserID: task.UserID, @@ -517,6 +591,11 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s PreDeductAmount: billing.InexactFloat64(task.PreDeductAmount), BillingSource: "task", TaskID: task.PublicTaskID, + + DurationSeconds: durationSeconds, + + // 任务的上游请求 ID = 调用追踪 ID(上游返回时),否则为任务句柄(轮询同款标识) + UpstreamRequestID: upstreamRequestID, } // 从结算结果填充计费快照 @@ -590,3 +669,19 @@ func parseChannelUseProxy(settings json.RawMessage) bool { } return s.UseProxy } + +// pollDebugLogEnabled 判定轮询渠道是否开启调试日志且匹配任务的租户/成员/密钥过滤 +// (ChannelBasicInfo.Settings 为 JSONB 原文,按需解析;解析失败视为未开启) +func pollDebugLogEnabled(settings json.RawMessage, task *common.AsyncTask) bool { + var s common.ChannelSettings + if len(settings) == 0 || json.Unmarshal(settings, &s) != nil { + return false + } + return s.DebugLogEnabled && s.DebugTargetMatch(task.TenantID, task.UserID, task.ApiKeyID) +} + +// pollStateChanged 判定本次轮询结果相对任务行当前持久化状态是否发生变化(状态或进度任一变化)。 +// 独立成纯函数以便回归测试;在 CAS 更新 task 之前调用,task 内即上一次轮询持久化的值 +func pollStateChanged(taskInfo *common.TaskInfo, task *common.AsyncTask) bool { + return string(taskInfo.Status) != task.Status || taskInfo.Progress != task.Progress +} diff --git a/internal/logic/task/async_polling_test.go b/internal/logic/task/async_polling_test.go index 9bb67f1..3a6d44c 100644 --- a/internal/logic/task/async_polling_test.go +++ b/internal/logic/task/async_polling_test.go @@ -1,6 +1,7 @@ package task import ( + "encoding/json" "testing" "time" @@ -33,3 +34,52 @@ func TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate(t *testing.T) { } } } + +// TestPollStateChanged 状态变化判定(轮询调试记录的节流依据): +// 状态或进度任一相对任务行当前持久化值变化才算变化,其余忽略 +func TestPollStateChanged(t *testing.T) { + task := &common.AsyncTask{Status: "IN_PROGRESS", Progress: "50%"} + + // 完全一致 → 未变化 + if pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "50%"}, task) { + t.Error("状态与进度均一致时不应视为变化") + } + // 状态变化(终态转移) + if !pollStateChanged(&common.TaskInfo{Status: "SUCCESS", Progress: "50%"}, task) { + t.Error("状态变化应视为变化") + } + // 仅进度变化 + if !pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "60%"}, task) { + t.Error("进度变化应视为变化") + } + // 空进度 → 有进度也算变化(提交后首次轮询) + if !pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "10%"}, + &common.AsyncTask{Status: "SUBMITTED", Progress: ""}) { + t.Error("空进度到有进度应视为变化") + } +} + +// TestPollDebugLogEnabled 轮询调试开关判定:JSONB 解析 + 目标过滤 AND 组合 +func TestPollDebugLogEnabled(t *testing.T) { + task := &common.AsyncTask{TenantID: 1, UserID: 2, ApiKeyID: 3} + + // 空/非法 settings 视为未开启 + if pollDebugLogEnabled(nil, task) || pollDebugLogEnabled([]byte("not-json"), task) { + t.Error("空或非法 settings 应视为未开启") + } + // 未开启 + off, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: false}) + if pollDebugLogEnabled(off, task) { + t.Error("DebugLogEnabled=false 应不开启") + } + // 开启且无过滤 → 匹配 + on, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: true}) + if !pollDebugLogEnabled(on, task) { + t.Error("开启且无过滤应匹配") + } + // 开启但租户不匹配 → 拒绝 + filtered, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: true, DebugLogTenantID: 99}) + if pollDebugLogEnabled(filtered, task) { + t.Error("租户过滤不匹配应拒绝") + } +} diff --git a/internal/logic/task/async_provider.go b/internal/logic/task/async_provider.go index 1b584a9..7183dba 100644 --- a/internal/logic/task/async_provider.go +++ b/internal/logic/task/async_provider.go @@ -53,6 +53,8 @@ func asyncTaskFromEntity(row *entity.TskModelTasks) *common.AsyncTask { FinishTime: timePtrFromGTime(row.FinishTime), CreatedAt: timeFromGTime(row.CreatedAt), UpdatedAt: timeFromGTime(row.UpdatedAt), + Deleted: row.Deleted, + DeletedAt: timePtrFromGTime(row.DeletedAt), } } @@ -187,11 +189,12 @@ func (p *AsyncProvider) UpdateTaskCAS(ctx context.Context, task *common.AsyncTas return nil } -// GetTaskByPublicID 根据公开任务 ID 查询 +// GetTaskByPublicID 根据公开任务 ID 查询(软删除的任务视为不存在) func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID string) (*common.AsyncTask, error) { var row *entity.TskModelTasks err := dao.TskModelTasks.Ctx(ctx). Where("public_task_id", publicTaskID). + Where("deleted", false). Scan(&row) if err != nil { return nil, gerror.Wrapf(err, "query async task failed: public_id=%s", publicTaskID) @@ -199,13 +202,14 @@ func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID stri return asyncTaskFromEntity(row), nil } -// GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询 +// GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询(软删除的任务视为不存在) func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTaskID string, userID int64, tenantID int64) (*common.AsyncTask, error) { var row *entity.TskModelTasks err := dao.TskModelTasks.Ctx(ctx). Where("public_task_id", publicTaskID). Where("user_id", userID). Where("tenant_id", tenantID). + Where("deleted", false). Scan(&row) if err != nil { return nil, gerror.Wrapf(err, "query async task failed: public_id=%s user_id=%d", publicTaskID, userID) @@ -262,6 +266,30 @@ func (p *AsyncProvider) GetUnsettledTasks(ctx context.Context, limit int) ([]*co return asyncTasksFromEntities(rows), nil } +// SoftDeleteTask 软删除任务(DELETE /v1/videos/{id})。 +// 仅标记 deleted,不动 status/计费字段:调用方保证只有终态且已结算的任务可删, +// 轮询 cron 的 GetNonTerminalTasks/GetUnsettledTasks 查询不感知 deleted 列。 +func (p *AsyncProvider) SoftDeleteTask(ctx context.Context, task *common.AsyncTask) error { + result, err := dao.TskModelTasks.Ctx(ctx). + Where("id", task.ID). + Where("tenant_id", task.TenantID). + Update(map[string]any{ + "deleted": true, + "deleted_at": gtime.Now(), + "result_url": "", // 清掉成品直链,避免软删后内部路径仍可寻址 + "updated_at": time.Now(), + }) + if err != nil { + return gerror.Wrapf(err, "soft delete async task failed: id=%d", task.ID) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("soft delete no rows affected: id=%d", task.ID) + } + g.Log().Infof(ctx, "[AsyncProvider] SoftDeleteTask: public_id=%s, tenant=%d", task.PublicTaskID, task.TenantID) + return nil +} + // GetChannelByID 获取渠道基本信息(含从 chn_channel_keys 解密的 API Key) func (p *AsyncProvider) GetChannelByID(ctx context.Context, channelID int64) (*common.ChannelBasicInfo, error) { var row *struct { diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index 155e7de..3ea6ce7 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -122,7 +122,7 @@ type SyncImageJob struct { Model string RequestBody []byte PreDeductAmount decimal.Decimal - Ratios map[string]float64 + Ratios map[string]any SubmitTime time.Time } @@ -447,6 +447,10 @@ func processSyncImageJob(job *SyncImageJob) { defer sess.Finish(context.WithoutCancel(ctx), false, 0) lastErr := "no available channel" + // 渠道调试日志:镜像 RelayHandler 重试循环的接线——首个开启调试且匹配过滤的尝试创建 + // 会话(段1 = 客户端提交的原始请求体;后台执行无客户端交互,段4 留空)。中间失败尝试 + // Submit 立即落库(is_final=false),成功尝试 MarkFinal 后提交最终记录 + var dbgSess *common.DebugSession for attempt := 0; attempt < syncImageMaxAttempts; attempt++ { d := sess.Next(ctx) if d == nil { @@ -465,15 +469,32 @@ func processSyncImageJob(job *SyncImageJob) { continue } - ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel) + var dbgAttempt *common.DebugAttempt + if sel.Settings.DebugLogEnabled && sel.Settings.DebugTargetMatch(job.TenantID, job.UserID, job.ApiKeyID) { + if dbgSess == nil { + dbgSess = common.NewDebugSession(job.RequestID, job.TenantID, job.UserID, job.ApiKeyID, "/v1/images/generations/async") + dbgSess.CaptureClientRequest(http.Header{"Content-Type": []string{"application/json"}}, job.RequestBody) + } + dbgAttempt = dbgSess.BeginTaskAttempt(&common.ChannelMeta{ + ChannelID: sel.ChannelID, + ChannelName: sel.ChannelName, + ChannelType: sel.ChannelType, + UpstreamModelName: sel.UpstreamModelName, + }, job.Model, "images_generations", attempt) + } + + ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel, dbgAttempt) if ok { sess.Finish(context.WithoutCancel(ctx), true, 0) settleSyncImageSuccess(ctx, job, sel, memW) + dbgAttempt.MarkFinal(nil) + dbgSess.FinalizeAndSubmit(0, 0) return } // 失败:上报健康并按 FSM 决策原地重试/换渠道/终止(绑定不删除) lastErr = perr + dbgAttempt.Submit(errors.New(perr)) dec, backoff := sess.Report(ctx, 0, errors.New(perr), dispatch.DeliveryResponseReceived, 0, 0) monitor.TrackDispatchRetry(dispatch.Classify(0, errors.New(perr), dispatch.DeliveryResponseReceived).String(), dec.String()) if dec == dispatch.DecisionAbort { @@ -491,11 +512,12 @@ func processSyncImageJob(job *SyncImageJob) { } // runImagePipelineWithRelease 管线执行期间保活调度租约(租约获取/释放由调度会话负责)。 -func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (ok bool, memW *memResponseWriter, failReason string) { +// dbg 为当前尝试的调试捕获器(nil = 渠道未开启调试),注入 ctx 供传输层镜像段2/3。 +func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (ok bool, memW *memResponseWriter, failReason string) { stopHeartbeat := make(chan struct{}) go refreshSyncImageChannelLease(sel.ChannelID, job.RequestID, stopHeartbeat) defer close(stopHeartbeat) - return runImagePipeline(ctx, job, sel) + return runImagePipeline(ctx, job, sel, dbg) } func refreshSyncImageChannelLease(channelID int64, requestID string, stop <-chan struct{}) { @@ -514,9 +536,14 @@ func refreshSyncImageChannelLease(channelID int64, requestID string, stop <-chan } // runImagePipeline 复刻 RelayHandler 的最小管线,把上游响应捕获到内存 writer。 -func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (bool, *memResponseWriter, string) { +func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (bool, *memResponseWriter, string) { + // 调试捕获器注入 ctx(nil 时透传),同步适配器经 NewRequestWithContext 携带到传输层 + pipeCtx := ctx + if dbg != nil { + pipeCtx = common.WithDebugAttempt(ctx, dbg.Capture) + } info := &common.RelayInfo{ - Context: ctx, + Context: pipeCtx, TenantID: job.TenantID, UserID: job.UserID, ApiKeyID: job.ApiKeyID, @@ -552,6 +579,7 @@ func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.Channe return false, nil, fmt.Sprintf("no adaptor for channelType %d", sel.ChannelType) } adaptor.Init(info) + dbg.CaptureProtocol(info) reader, err := adaptor.ConvertRequest(ctx, info, job.RequestBody) if err != nil { @@ -559,7 +587,8 @@ func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.Channe } // 上游长连接用后台 context(不随停机被斩断),阻塞 10–60s。 - resp, err := adaptor.DoRequest(context.WithoutCancel(ctx), info, reader) + // WithoutCancel 保留 ctx values,调试捕获器随请求到达传输层 + resp, err := adaptor.DoRequest(context.WithoutCancel(pipeCtx), info, reader) if err != nil { return false, nil, fmt.Sprintf("do request: %v", err) } diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index 3fcbca4..04b9ba3 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -659,6 +659,9 @@ func (s *sTenant) ApiKeyReveal(ctx context.Context, req *v1.TenantApiKeyRevealRe return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥解密失败") } + // 查看明文属于敏感读取(Key 可长期调用计费接口),落敏感访问日志留痕 + lcommon.LogSensitiveAccess(ctx, userID, "tenant", "api_key", req.Id, "reveal", "查看 API Key 明文") + return &v1.TenantApiKeyRevealRes{ Key: plainKey, KeyPrefix: info.KeyPrefix, diff --git a/internal/logic/tenant/audit_config.go b/internal/logic/tenant/audit_config.go index 600ea0b..84d6e58 100644 --- a/internal/logic/tenant/audit_config.go +++ b/internal/logic/tenant/audit_config.go @@ -208,6 +208,10 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ conditions = append(conditions, "path LIKE ?") args = append(args, "%"+req.Path+"%") } + if req.Model != "" { + conditions = append(conditions, "model_name = ?") + args = append(args, req.Model) + } if req.StatusCode > 0 { conditions = append(conditions, "status_code = ?") args = append(args, req.StatusCode) @@ -239,8 +243,11 @@ func (s *sTenant) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequ }, nil } + // 列表白名单:表格列 + EnrichAuditRecords 回填名称所需的关联 ID; + // user_agent 供「客户端」列解析展示。request_id / query_params / task_completed_at + // 等列表不展示的字段经详情接口获取。 dataM := auditDB.Ctx(ctx).Model("aud_request_logs").Safe(). - Fields("id, request_id, tenant_id, user_id, project_id, api_key_id, method, path, query_params, status_code, client_ip, user_agent, latency_ms, first_token_ms, tenant_audit_level as audit_level, task_id, task_status, task_completed_at, created_at"). + Fields("id, tenant_id, user_id, project_id, api_key_id, method, path, model_name, status_code, client_ip, user_agent, latency_ms, first_token_ms, tenant_audit_level as audit_level, task_id, task_status, created_at"). OrderDesc("created_at"). Page(page, pageSize) if where != "" { diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index b573280..f4acadd 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -23,6 +23,19 @@ import ( "github.com/qianfree/team-api/internal/utility/crypto" ) +// GetRegisterRateLimitStatus 查询当前 IP 的注册限流状态(注册页提示用,公开端点,只读不计数)。 +func (s *sTenant) GetRegisterRateLimitStatus(ctx context.Context, _ *v1.TenantRegisterRateLimitReq) (*v1.TenantRegisterRateLimitRes, error) { + status := common.GetRegisterRateLimitStatus(ctx, g.RequestFromCtx(ctx).GetClientIp()) + return &v1.TenantRegisterRateLimitRes{ + HourlyLimit: status.HourlyLimit, + HourlyRemaining: status.HourlyRemaining, + HourlyResetSeconds: status.HourlyResetSeconds, + DailyLimit: status.DailyLimit, + DailyRemaining: status.DailyRemaining, + DailyResetSeconds: status.DailyResetSeconds, + }, nil +} + // Register handles tenant registration. func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.TenantRegisterRes, error) { // Check if registration is enabled @@ -59,10 +72,10 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. } } - // 组织名称:留空则用用户名派生默认值;用户显式传入才校验禁用词 + // 组织名称:留空则直接用用户名;用户显式传入才校验禁用词 if !userProvidedName { - // 用户名较长时“xxx 的组织”可能超出组织名称显示宽度上限,按宽度截断兜底 - tenantName = common.TruncateToDisplayWidth(fmt.Sprintf("%s 的组织", username), common.TenantNameMaxDisplayWidth) + // 用户名较长时可能超出组织名称显示宽度上限,按宽度截断兜底 + tenantName = common.TruncateToDisplayWidth(username, common.TenantNameMaxDisplayWidth) } else { if err := common.ValidateTenantName(tenantName); err != nil { return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error()) @@ -231,6 +244,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. if err != nil { return nil, gerror.Wrapf(err, "create session") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) tokenPair, err := common.GenerateTokenPair(ctx, ownerUserID, "tenant", "owner", tenantID, sessionID, jti) if err != nil { @@ -399,12 +413,13 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant if !crypto.VerifyPassword(req.Password, user.PasswordHash) { _ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误") - // 原子递增失败次数 + // 原子递增失败次数;锁定阈值与时长取自安全配置(sys_options) updateData := do.TntUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")} failedAttempts := user.FailedAttempts + 1 + maxAttempts, lockoutMinutes := common.LoginLockoutPolicy(ctx) - if failedAttempts >= 5 { - lockedUntil := time.Now().Add(30 * time.Minute) + if failedAttempts >= maxAttempts { + lockedUntil := time.Now().Add(time.Duration(lockoutMinutes) * time.Minute) updateData.LockedUntil = gtime.NewFromTime(lockedUntil) _, err := dao.TntUsers.Ctx(ctx). Where("id", user.Id). @@ -413,7 +428,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant g.Log().Errorf(ctx, "更新账号锁定状态失败: %v", err) } return nil, common.NewBusinessError(consts.CodeAccountLocked, - "连续 5 次密码错误,账号已锁定 30 分钟") + fmt.Sprintf("连续 %d 次密码错误,账号已锁定 %d 分钟", maxAttempts, lockoutMinutes)) } _, err := dao.TntUsers.Ctx(ctx). @@ -476,6 +491,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant if err != nil { return nil, gerror.Wrapf(err, "create session") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, tenant.Id, sessionID, jti) if err != nil { @@ -551,12 +567,14 @@ func (s *sTenant) Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.Tena // Refresh handles token refresh for tenant users. func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) { - refreshTokenHash := common.HashRefreshToken(req.RefreshToken) - - session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash) + // 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销 + session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken) if err != nil { return nil, common.NewUnauthorizedError("会话不存在") } + if replayed { + return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录") + } if session == nil { return nil, common.NewUnauthorizedError("会话已过期或不存在") } @@ -578,15 +596,10 @@ func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.Te return nil, common.NewUnauthorizedError(consts.MsgUnauthorized) } - newRefreshToken, err := common.GenerateRefreshToken() - if err != nil { - return nil, err - } - newRefreshTokenHash := common.HashRefreshToken(newRefreshToken) - + // 轮换令牌(新令牌内嵌会话 ID,供下次重放检测定位) ipAddress := g.RequestFromCtx(ctx).GetClientIp() deviceInfo := common.ExtractDeviceInfo(ctx) - err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo) + newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo) if err != nil { return nil, err } diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 76ba91b..5d1492c 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -116,19 +116,24 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr CreatedAt *gtime.Time `json:"created_at"` } - var records []transactionRow - var err error - var total int - err = query.Fields("bil_transactions.id, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.frozen_after, bil_transactions.related_id, bil_transactions.related_type, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.project_id, bil_transactions.api_key_id, bil_transactions.task_id, bil_transactions.created_at"). - LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id"). - OrderDesc("bil_transactions.created_at"). - Page(page, pageSize). - ScanAndCount(&records, &total, false) + // COUNT 与列表拆开执行:计数不需要用户名称列,筛选条件也不依赖 JOIN + //(Username 过滤在 BuildTransactionQuery 内是 EXISTS 子查询),大表 + // bil_transactions 的计数免背联表。不用 ScanAndCount —— 它的 COUNT + // 会携带模型上的全部 JOIN。 + total, err := query.Count() if err != nil { return nil, err } - if records == nil { - records = []transactionRow{} + + records := []transactionRow{} + if total > 0 { + if err := query.Fields("bil_transactions.id, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.frozen_after, bil_transactions.related_id, bil_transactions.related_type, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.project_id, bil_transactions.api_key_id, bil_transactions.task_id, bil_transactions.created_at"). + LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id"). + OrderDesc("bil_transactions.created_at"). + Page(page, pageSize). + Scan(&records); err != nil { + return nil, err + } } list := make([]map[string]any, 0, len(records)) @@ -161,6 +166,63 @@ func (s *sTenant) WalletTransactions(ctx context.Context, req *v1.TenantWalletTr }, nil } +// tenantUsageLogFilter 用量日志查询的筛选参数(列表 / 统计 / 导出三个入口共用)。 +type tenantUsageLogFilter struct { + TenantID int64 + UserID int64 + Role string + Username string + Model string + Status string + RequestType int + StartDate string + EndDate string +} + +// buildTenantUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(不含 WHERE 前缀; +// tenant_id 恒在条件里,结果必非空)。三个入口共用,保证筛选口径一致。 +// +// 条件只引用主表别名 u,不依赖任何 JOIN —— 计数与统计路径因此可以完全不联表, +// 只有需要展示名称的列表 / 导出才挂 LEFT JOIN。租户隔离(u.tenant_id)与 +// member 角色只见自己(u.user_id)在此统一强制,调用方不得绕过。 +func buildTenantUsageLogFilter(f tenantUsageLogFilter) (where string, args []any) { + conditions := []string{"u.tenant_id = ?"} + args = append(args, f.TenantID) + + // member 角色只能查看自己的用量日志 + if f.Role == "member" { + conditions = append(conditions, "u.user_id = ?") + args = append(args, f.UserID) + } else if f.Username != "" { + // EXISTS 子查询替代对 JOIN 别名 t.username 的引用:条件不再依赖联表, + // 且避免把 tnt_users 拖成驱动表(EXISTS 内是主键等值探测,代价低)。 + // 语义与 LEFT JOIN 后过滤一致:用户不存在的行同样被过滤掉。 + conditions = append(conditions, "EXISTS (SELECT 1 FROM tnt_users ut WHERE ut.id = u.user_id AND ut.tenant_id = u.tenant_id AND ut.username LIKE ?)") + args = append(args, "%"+f.Username+"%") + } + if f.Model != "" { + conditions = append(conditions, "u.model_name = ?") + args = append(args, f.Model) + } + if f.Status != "" { + conditions = append(conditions, "u.status = ?") + args = append(args, f.Status) + } + if f.RequestType > 0 { + conditions = append(conditions, "u.request_type = ?") + args = append(args, f.RequestType) + } + if f.StartDate != "" { + conditions = append(conditions, "u.created_at >= ?") + args = append(args, common.StartOfRange(f.StartDate)) + } + if f.EndDate != "" { + conditions = append(conditions, "u.created_at <= ?") + args = append(args, common.EndOfRange(f.EndDate)) + } + return strings.Join(conditions, " AND "), args +} + // UsageLogs 获取租户用量日志 func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v1.TenantUsageLogsRes, error) { if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { @@ -175,46 +237,25 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v role := middleware.GetUserRole(ctx) page, pageSize := common.NormalizePagination(req.Page, req.PageSize) - var conditions []string - var args []any + where, args := buildTenantUsageLogFilter(tenantUsageLogFilter{ + TenantID: tenantID, + UserID: userID, + Role: role, + Username: req.Username, + Model: req.Model, + Status: req.Status, + RequestType: req.RequestType, + StartDate: req.StartDate, + EndDate: req.EndDate, + }) - conditions = append(conditions, "u.tenant_id = ?") - args = append(args, tenantID) - - // member 角色只能查看自己的用量日志 - if role == "member" { - conditions = append(conditions, "u.user_id = ?") - args = append(args, userID) - } else if req.Username != "" { - conditions = append(conditions, "t.username LIKE ?") - args = append(args, "%"+req.Username+"%") - } - if req.Model != "" { - conditions = append(conditions, "u.model_name = ?") - args = append(args, req.Model) - } - if req.Status != "" { - conditions = append(conditions, "u.status = ?") - args = append(args, req.Status) - } - if req.RequestType > 0 { - conditions = append(conditions, "u.request_type = ?") - args = append(args, req.RequestType) - } - if req.StartDate != "" { - conditions = append(conditions, "u.created_at >= ?") - args = append(args, common.StartOfRange(req.StartDate)) - } - if req.EndDate != "" { - conditions = append(conditions, "u.created_at <= ?") - args = append(args, common.EndOfRange(req.EndDate)) - } - - where := strings.Join(conditions, " AND ") // 列表查询不 JOIN mdl_models(model_display_name 仅导出使用) fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id" - countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + " WHERE " + where + // 计数不展示任何名称,且筛选条件只引用 u.*(Username 过滤是 EXISTS 子查询), + // 3 个展示用 LEFT JOIN 整体剥离 —— bil_usage_logs 是分区大表,列表每翻一页 + // 都要重算一次计数,这里是本接口最重的一次查询。 + countSQL := "SELECT COUNT(*) AS total FROM bil_usage_logs u WHERE " + where countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...) if err != nil { return nil, err @@ -224,19 +265,18 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v total = countResult[0]["total"].Int() } - // 白名单查询:仅返回租户端展示所需字段。 + // 白名单查询:仅返回租户端列表展示字段(表格列 + Token / 费用悬浮明细)。 // 禁止改回 SELECT u.* —— bil_usage_logs 含平台侧敏感字段,曾因此泄露 // upstream_model(模型映射)、account_cost(上游成本/利润)、upstream_endpoint、 - // billing_snapshot(含上游模型与模型倍率)给租户。新增展示需求时在此追加列。 + // billing_snapshot(含上游模型与模型倍率)给租户。billing_summary / error_message / + // user_agent 等大字段只进详情接口(UsageLogDetail)。新增展示需求时在此追加列。 dataSQL := fmt.Sprintf( - `SELECT u.id, u.request_id, u.task_id, u.api_key_id, u.model_name, u.relay_mode, u.inbound_endpoint, - u.request_type, u.billing_mode, u.billing_source, u.rate_multiplier, u.status, u.retry_index, - u.stream_end_reason, u.error_message, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, + `SELECT u.id, u.api_key_id, u.model_name, u.request_type, u.billing_mode, u.rate_multiplier, u.status, u.retry_index, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, - u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.image_count, u.image_size, + u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, - u.latency_ms, u.first_token_ms, u.created_at, u.billing_summary, + u.latency_ms, u.first_token_ms, u.created_at, COALESCE(t.username, '') AS username, COALESCE(p.name, '') AS project_name, COALESCE(ak.name, '') AS api_key_name FROM %s WHERE %s ORDER BY u.created_at DESC LIMIT ? OFFSET ?`, fromClause, where, @@ -274,6 +314,132 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v }, nil } +// UsageLogDetail 获取单条用量日志详情:列表白名单 + 仅详情展示的字段 +// (request_id / task_id / billing_summary / error_message / user_agent 等)。 +// 平台侧敏感字段(upstream_model、account_cost、upstream_endpoint、billing_snapshot) +// 与列表同样禁止返回。member 角色只能查看自己的记录。 +// 性能:bil_usage_logs 按 created_at 月分区,仅按 id 查询无法分区裁剪,为各分区 +// Append 索引探测(见管理后台 GetUsageLogDetail 注释),当前量级可接受。 +func (s *sTenant) UsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (*v1.TenantUsageLogDetailRes, error) { + tenantID := middleware.GetTenantID(ctx) + userID := middleware.GetUserID(ctx) + role := middleware.GetUserRole(ctx) + + // 双键校验:id + tenant_id 强制隔离;member 只能看自己的记录 + conditions := "u.id = ? AND u.tenant_id = ?" + args := []any{req.Id, tenantID} + if role == "member" { + conditions += " AND u.user_id = ?" + args = append(args, userID) + } + + dataSQL := `SELECT u.id, u.api_key_id, u.model_name, u.request_type, u.billing_mode, u.rate_multiplier, u.status, u.retry_index, + u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, + u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, + u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, + u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, + u.latency_ms, u.first_token_ms, u.created_at, + u.request_id, u.task_id, u.relay_mode, u.inbound_endpoint, u.billing_source, u.stream_end_reason, + u.error_message, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, + u.image_count, u.image_size, u.billing_summary, + COALESCE(t.username, '') AS username, COALESCE(p.name, '') AS project_name, COALESCE(ak.name, '') AS api_key_name + FROM bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id + WHERE ` + conditions + ` LIMIT 1` + result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, args...) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, common.NewNotFoundError("用量日志") + } + + m := make(map[string]any, len(result[0])) + for k, v := range result[0] { + switch raw := v.Val().(type) { + case []byte: + s := string(raw) + if f, err := strconv.ParseFloat(s, 64); err == nil { + m[k] = f + } else { + m[k] = s + } + default: + m[k] = raw + } + } + return &v1.TenantUsageLogDetailRes{Data: m}, nil +} + +// UsageLogsSummary 用量日志统计汇总(与 UsageLogs 共用筛选口径:强制 tenant_id 隔离, +// member 角色只能统计自己的日志;总费用与租户端列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost) +func (s *sTenant) UsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (*v1.TenantUsageLogsSummaryRes, error) { + if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { + return nil, err + } + if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { + return nil, err + } + + tenantID := middleware.GetTenantID(ctx) + userID := middleware.GetUserID(ctx) + role := middleware.GetUserRole(ctx) + + where, args := buildTenantUsageLogFilter(tenantUsageLogFilter{ + TenantID: tenantID, + UserID: userID, + Role: role, + Username: req.Username, + Model: req.Model, + Status: req.Status, + RequestType: req.RequestType, + StartDate: req.StartDate, + EndDate: req.EndDate, + }) + + // 统计聚合不展示名称,Username 过滤已由 EXISTS 子查询承担,全程无需联表: + // 大表 SUM 不再背负任何 join。 + summarySQL := `SELECT + COALESCE(SUM(COALESCE(NULLIF(u.actual_cost, 0), u.total_cost)), 0) AS total_cost, + COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens, + COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens, + COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read + FROM bil_usage_logs u WHERE ` + where + + summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...) + if err != nil { + return nil, err + } + // 无 GROUP BY 的聚合必返回一行;空结果兜底为零值 + if len(summaryResult) == 0 { + return &v1.TenantUsageLogsSummaryRes{}, nil + } + + var row struct { + TotalCost float64 `json:"total_cost"` + TotalOutputTokens int64 `json:"total_output_tokens"` + TotalInputTokens int64 `json:"total_input_tokens"` + TotalCacheRead int64 `json:"total_cache_read"` + } + if err := summaryResult[0].Struct(&row); err != nil { + return nil, err + } + + // 缓存读取占比 = cache_read / input_tokens。 + // input_tokens 入库口径为「含缓存的总输入」(见 relay/common/usage.go TotalInputTokens), + // cache_read 是其子集,分母直接取总和即可,禁止再加 cache_read(会重复计入导致占比偏低)。 + // 口径与 admin 侧 GetUsageLogSummary 保持一致。 + var cacheReadRatio float64 + if row.TotalInputTokens > 0 { + cacheReadRatio = float64(row.TotalCacheRead) / float64(row.TotalInputTokens) * 100 + } + + return &v1.TenantUsageLogsSummaryRes{ + TotalCost: row.TotalCost, + TotalOutputTokens: row.TotalOutputTokens, + CacheReadRatio: cacheReadRatio, + }, nil +} + // ExportUsageLogs exports the tenant usage logs as CSV or Excel. func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) { if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil { @@ -282,6 +448,10 @@ func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsEx if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil { return nil, err } + // 导出护栏:时间窗必填且不超上限(与管理后台导出同一规则) + if err := common.ValidateExportTimeWindow(req.StartDate, req.EndDate, common.UsageLogExportMaxWindowDays); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) userID := middleware.GetUserID(ctx) @@ -306,58 +476,39 @@ func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsEx Columns: columns, } - var conditions []string - var args []any + where, args := buildTenantUsageLogFilter(tenantUsageLogFilter{ + TenantID: tenantID, + UserID: userID, + Role: role, + Username: req.Username, + Model: req.Model, + Status: req.Status, + RequestType: req.RequestType, + StartDate: req.StartDate, + EndDate: req.EndDate, + }) - conditions = append(conditions, "u.tenant_id = ?") - args = append(args, tenantID) - - // member 角色只能导出自己的用量日志 - if role == "member" { - conditions = append(conditions, "u.user_id = ?") - args = append(args, userID) - } else if req.Username != "" { - conditions = append(conditions, "t.username LIKE ?") - args = append(args, "%"+req.Username+"%") - } - if req.Model != "" { - conditions = append(conditions, "u.model_name = ?") - args = append(args, req.Model) - } - if req.Status != "" { - conditions = append(conditions, "u.status = ?") - args = append(args, req.Status) - } - if req.RequestType > 0 { - conditions = append(conditions, "u.request_type = ?") - args = append(args, req.RequestType) - } - if req.StartDate != "" { - conditions = append(conditions, "u.created_at >= ?") - args = append(args, common.StartOfRange(req.StartDate)) - } - if req.EndDate != "" { - conditions = append(conditions, "u.created_at <= ?") - args = append(args, common.EndOfRange(req.EndDate)) - } - - where := strings.Join(conditions, " AND ") fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id LEFT JOIN mdl_models mdl ON u.model_name = mdl.model_id" return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset(游标)翻页替代 OFFSET:OFFSET 每翻一批都要重新扫过并丢弃前面的 + // 所有行,深翻页代价线性上涨;id 游标(bigserial 单调递增,走 PK 索引) + // 让每批从上一批断点续读。排序 created_at DESC → id DESC,追加写表近似同序, + // 且避免时间戳做游标时亚秒精度在驱动往返中丢失导致的跳行/重行。 + var cursorID int64 for { - dataSQL := fmt.Sprintf( - `SELECT u.id, COALESCE(t.username, '') AS username, COALESCE(mdl.model_name, '') AS model_display_name, u.model_name, u.request_type, - u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at - FROM %s WHERE %s ORDER BY u.created_at DESC LIMIT 1000 OFFSET ?`, - fromClause, where, - ) - exportArgs := make([]any, len(args)+1) - copy(exportArgs, args) - exportArgs[len(args)] = offset + dataSQL := `SELECT u.id, COALESCE(t.username, '') AS username, COALESCE(mdl.model_name, '') AS model_display_name, u.model_name, u.request_type, + u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at + FROM ` + fromClause + ` WHERE ` + where + exportArgs := append([]any{}, args...) + if cursorID > 0 { + dataSQL += " AND u.id < ?" + exportArgs = append(exportArgs, cursorID) + } + dataSQL += " ORDER BY u.id DESC LIMIT 1000" result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, exportArgs...) if err != nil { + g.Log().Errorf(ctx, "ExportUsageLogs: query batch failed (cursor id=%d): %v", cursorID, err) return } for _, row := range result { @@ -378,11 +529,11 @@ func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsEx if !yield(m) { return } + cursorID = row["id"].Int64() } if len(result) < 1000 { break } - offset += 1000 } }) } @@ -412,7 +563,9 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa } return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行; + // 排序取 id DESC(追加写表上与 created_at DESC 近似同序) + var cursorID int64 for { type transactionRow struct { Id int64 `json:"id"` @@ -447,11 +600,14 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa if req.ModelName != "" { q = q.Where("model_name LIKE ?", "%"+req.ModelName+"%") } + if cursorID > 0 { + q = q.Where("id < ?", cursorID) + } var records []transactionRow err := q.Fields("id, type, amount, balance_after, user_id, request_id, model_name, description, created_at"). - OrderDesc("created_at"). - Limit(1000).Offset(offset). + OrderDesc("id"). + Limit(1000). Scan(&records) if err != nil { return @@ -467,11 +623,11 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa }) { return } + cursorID = r.Id } if len(records) < 1000 { break } - offset += 1000 } }) } diff --git a/internal/logic/tenant/help_center.go b/internal/logic/tenant/help_center.go index 35d14be..4d51914 100644 --- a/internal/logic/tenant/help_center.go +++ b/internal/logic/tenant/help_center.go @@ -3,14 +3,26 @@ package tenant import ( "context" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/logic/common" ) +// helpRateLimit 帮助中心公开接口统一入口限流(非 HTTP 上下文跳过) +func helpRateLimit(ctx context.Context) error { + if r := g.RequestFromCtx(ctx); r != nil { + return common.CheckHelpCenterRateLimit(ctx, r.GetClientIp()) + } + return nil +} + // ListHelpPublicCategories 公开帮助分类列表(树结构) func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublicCategoryListReq) (*v1.HelpPublicCategoryListRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, err := common.ListPublicCategories(ctx) if err != nil { return nil, err @@ -22,6 +34,9 @@ func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublic // ListHelpPublicArticles 分类下的文章列表 func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublicArticleListReq) (*v1.HelpPublicArticleListRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, total, page, pageSize, err := common.ListPublicArticles(ctx, req.CategorySlug, req.Page, req.PageSize) if err != nil { return nil, err @@ -50,6 +65,9 @@ func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublic // GetHelpPublicArticle 文章详情 func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicArticleGetReq) (*v1.HelpPublicArticleGetRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } detail, err := common.GetPublicArticle(ctx, req.Slug) if err != nil { return nil, err @@ -75,6 +93,9 @@ func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicAr // SearchHelpPublicArticles 搜索文章 func (s *sTenant) SearchHelpPublicArticles(ctx context.Context, req *v1.HelpPublicSearchReq) (*v1.HelpPublicSearchRes, error) { + if err := helpRateLimit(ctx); err != nil { + return nil, err + } items, total, page, pageSize, err := common.SearchPublicArticles(ctx, req.Query, req.Page, req.PageSize) if err != nil { return nil, err diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index c164979..3c3e0e0 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -16,6 +16,14 @@ import ( // InvitationList returns a paginated list of invitation records for the tenant. func (s *sTenant) InvitationList(ctx context.Context, req *v1.TenantInvitationListReq) (*v1.TenantInvitationListRes, error) { + // 邀请码可直接兑换账号,active 邀请的完整 code 会随 invite_url 返回, + // member 拿到即可自助注册出邀请角色(可为 admin)的账号 —— 必须与 + // 邀请创建/撤销同门槛,限 owner/admin。 + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) page, pageSize := common.NormalizePagination(req.Page, req.PageSize) diff --git a/internal/logic/tenant/level_benefits.go b/internal/logic/tenant/level_benefits.go index 172ce9c..ce9b6ce 100644 --- a/internal/logic/tenant/level_benefits.go +++ b/internal/logic/tenant/level_benefits.go @@ -11,6 +11,12 @@ import ( // GetLevelBenefits returns all level configurations and the tenant's current level info. func (s *sTenant) GetLevelBenefits(ctx context.Context, req *v1.TenantLevelBenefitsReq) (*v1.TenantLevelBenefitsRes, error) { + // 返回值含组织累计充值额(bil_wallets.cumulative_recharge,财务数据), + // member 角色不应看到;组织页面前端本就限 owner,此处与组织管理同门槛 + if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) // Query all level configs ordered by level diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 5052c0c..141c5a5 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -2,9 +2,9 @@ package tenant import ( "context" - "encoding/json" "fmt" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" @@ -14,20 +14,14 @@ import ( "github.com/qianfree/team-api/internal/model/entity" ) -// marketplacePricingRow 定价锚点行(min_tokens=0):全部计费模式的基础价载体 -// (token 默认价 / tiered 首档价 / per_request 单价),另带时段定价与对外展示字段。 -// 显式列名扫描进独立结构体,与 do/entity 生成物解耦(新展示列需迁移落库 + gf gen dao 后才进生成物)。 +// marketplacePricingRow 模型定价行(每模型一行):billing_mode + pricing JSONB 为计费真相, +// 另带对外展示字段。显式列名扫描进独立结构体,与 do/entity 生成物解耦。 type marketplacePricingRow struct { - ModelID int64 `json:"model_id"` - BillingMode string `json:"billing_mode"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - CacheReadPrice float64 `json:"cache_read_price"` - CacheCreationPrice float64 `json:"cache_creation_price"` - TimeSegments string `json:"time_segments"` - DiscountLabel *string `json:"discount_label"` - PriceChangeNote *string `json:"price_change_note"` + ModelID int64 `json:"model_id"` + BillingMode string `json:"billing_mode"` + Pricing string `json:"pricing"` // pricing JSONB(唯一真相) + DiscountLabel *string `json:"discount_label"` + PriceChangeNote *string `json:"price_change_note"` } // GetModelList 获取模型广场列表(从默认模型分组加载) @@ -48,24 +42,32 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: 0, Page: req.Page, PageSize: req.PageSize, + Vendors: []string{}, }, nil } - // 2. 构建查询:通过分组关联查询模型 - m := dao.MdlModels.Ctx(ctx). - LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id"). - Where("mdl_group_models.group_id", defaultGroup.Id). - Where("mdl_models.status", "active") - - // 搜索关键词 - if req.Keyword != "" { - keyword := "%" + req.Keyword + "%" - m = m.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword) + // 2. 构建查询:通过分组关联查询模型(厂商 facet 与主列表共用同一套基础条件, + // 用闭包各自构建链路,避免 gf Model 链式分支共享底层状态的歧义) + newQuery := func() *gdb.Model { + q := dao.MdlModels.Ctx(ctx). + LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id"). + Where("mdl_group_models.group_id", defaultGroup.Id). + Where("mdl_models.status", "active") + if req.Keyword != "" { + keyword := "%" + req.Keyword + "%" + q = q.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword) + } + if req.Category != "" { + q = q.Where("mdl_models.category", req.Category) + } + return q } - // 分类筛选 - if req.Category != "" { - m = m.Where("mdl_models.category", req.Category) + m := newQuery() + + // 厂商筛选 + if req.Vendor != "" { + m = m.Where("mdl_models.vendor", req.Vendor) } // 排序:按类别、模型名称 @@ -77,6 +79,23 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) return nil, err } + // 3.1 厂商 facet:在当前关键词/分类条件下(忽略厂商筛选本身)有模型的厂商去重列表, + // 前端据此只渲染实际有模型的厂商筛选项,保证点任一厂商都有结果 + var vendorRows []struct { + Vendor string `json:"vendor"` + } + err = newQuery(). + Where("mdl_models.vendor <> ''"). + Fields("DISTINCT mdl_models.vendor"). + Scan(&vendorRows) + if err != nil { + return nil, err + } + vendors := make([]string, 0, len(vendorRows)) + for _, r := range vendorRows { + vendors = append(vendors, r.Vendor) + } + // 4. 分页查询模型 var models []*entity.MdlModels err = m.Fields("mdl_models.*"). @@ -93,6 +112,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: 0, Page: req.Page, PageSize: req.PageSize, + Vendors: vendors, }, nil } @@ -104,7 +124,6 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) var pricings []marketplacePricingRow err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", modelIds). - Where("min_tokens", 0). // 锚点行承载全部计费模式的基础价与展示字段 Scan(&pricings) if err != nil { return nil, err @@ -128,6 +147,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: total, Page: req.Page, PageSize: req.PageSize, + Vendors: vendors, }, nil } @@ -162,11 +182,10 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR return nil, fmt.Errorf("模型不存在或未公开") } - // 3. 查询价格(锚点行:全部计费模式的基础价 + 时段/展示字段) + // 3. 查询价格(每模型一行:billing_mode + pricing JSONB + 展示字段) var pricing *marketplacePricingRow err = dao.MdlPricing.Ctx(ctx). Where("model_id", model.Id). - Where("min_tokens", 0). Scan(&pricing) if err != nil { return nil, err @@ -186,6 +205,7 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md ModelId: model.ModelId, ModelName: model.ModelName, Category: model.Category, + Vendor: model.Vendor, Description: model.Description, MaxContextTokens: model.MaxContextTokens, MaxOutputTokens: model.MaxOutputTokens, @@ -215,14 +235,21 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md billingMode = "token" } item.BillingMode = billingMode - // 锚点行价即对外展示价(数据库存的就是每 1M token 美元价,直接返回): - // token=默认价,tiered=首档价(前端标「起」),per_request=按次单价 - item.InputPrice = pricing.InputPrice - item.OutputPrice = pricing.OutputPrice - item.CacheReadPrice = pricing.CacheReadPrice - item.CacheCreationPrice = pricing.CacheCreationPrice - if pricing.PerRequestPrice != nil { - item.PerRequestPrice = *pricing.PerRequestPrice + + // pricing JSONB 解析(唯一真相):token=默认价,tiered=首档价(前端标「起」),per_request=按次单价 + blob := billing.ParsePricingBlob(pricing.Pricing) + baseInput, baseOutput, baseCacheRead, baseCacheCreation, basePerRequest := fillFromBlob(blob) + item.InputPrice = baseInput + item.OutputPrice = baseOutput + item.CacheReadPrice = baseCacheRead + item.CacheCreationPrice = baseCacheCreation + if basePerRequest != nil { + item.PerRequestPrice = *basePerRequest + } + // 按秒/特殊计费:矩阵来自平台 pricing JSONB(展示用);special 的矩阵是 + // 输出生成组件的参考单价(素材组件单价在方案配置中,不随广场下发) + if (billingMode == "per_second" || billingMode == billing.BillingModeSpecial) && blob != nil { + item.PerSecondPrices = blob.Prices } if pricing.DiscountLabel != nil { item.DiscountLabel = *pricing.DiscountLabel @@ -231,16 +258,14 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md item.PriceChangeNote = *pricing.PriceChangeNote } - // 时段价目:平台基础价 × 时段乘数换算(tiered 用锚点首档价换算,与 buildTiers 首档口径一致) - if pricing.TimeSegments != "" && pricing.TimeSegments != "null" { - var segments []billing.TimeSegment - if err := json.Unmarshal([]byte(pricing.TimeSegments), &segments); err == nil && len(segments) > 0 { - var tiers []v1.PricingTierItem - if billingMode == "tiered" { - tiers = append(tiers, v1.PricingTierItem{InputPrice: pricing.InputPrice, OutputPrice: pricing.OutputPrice}) - } - item.TimePrices = buildTimePrices(segments, billingMode, &pricing.InputPrice, &pricing.OutputPrice, pricing.PerRequestPrice, tiers) + // 时段价目:平台基础价 × 时段乘数换算(tiered 用首档价换算,与 buildTiers 首档口径一致) + if blob != nil && len(blob.TimeSegments) > 0 { + var tiers []v1.PricingTierItem + if billingMode == "tiered" && len(blob.Tiers) > 0 { + tiers = append(tiers, v1.PricingTierItem{InputPrice: blob.Tiers[0].InputPrice, OutputPrice: blob.Tiers[0].OutputPrice}) } + in, out := baseInput, baseOutput + item.TimePrices = buildTimePrices(blob.TimeSegments, billingMode, &in, &out, basePerRequest, tiers, item.PerSecondPrices) } return item diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 4851b61..e69a8b7 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -49,18 +49,18 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq) } var users []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - LockedUntil string `json:"locked_until"` - CreatedAt string `json:"created_at"` - QuotaType string `json:"quota_type"` - QuotaLimit float64 `json:"quota_limit"` - QuotaPeriod string `json:"quota_period"` - UpdatedAt string `json:"updated_at"` + Id int64 `json:"id" orm:"id"` + Username string `json:"username" orm:"username"` + Email string `json:"email" orm:"email"` + DisplayName string `json:"display_name" orm:"display_name"` + Role string `json:"role" orm:"role"` + Status string `json:"status" orm:"status"` + LockedUntil string `json:"locked_until" orm:"locked_until"` + CreatedAt string `json:"created_at" orm:"created_at"` + QuotaType string `json:"quota_type" orm:"quota_type"` + QuotaLimit float64 `json:"quota_limit" orm:"quota_limit"` + QuotaPeriod string `json:"quota_period" orm:"quota_period"` + UpdatedAt string `json:"updated_at" orm:"updated_at"` } err = model.OrderDesc("id"). Page(page, pageSize). @@ -81,8 +81,8 @@ func (s *sTenant) ListMembers(ctx context.Context, req *v1.TenantMemberListReq) hasScopeMap := make(map[int64]bool) if len(userIds) > 0 { var scopeRows []struct { - UserId int64 `json:"user_id"` - ModelId int64 `json:"model_id"` + UserId int64 `json:"user_id" orm:"user_id"` + ModelId int64 `json:"model_id" orm:"model_id"` } if err = dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). @@ -428,6 +428,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) if err != nil { return nil, gerror.Wrapf(err, "创建会话失败") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", invitation.Role, tenantID, sessionID, jti) if err != nil { @@ -815,7 +816,15 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda } // ResetMemberPassword resets a member's password. Only admins can reset other members' passwords. +// +// 角色校验此前缺失:只挡了「不能重置自己」和「不能重置 owner」,任意 member 都能改掉 +// 同组织 admin 的密码并登录该账号,等于组织内横向提权。与相邻的 UnlockMember / +// RemoveMember / UpdateMemberRole 保持同一道闸门。 func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } if err := requireTeamEnabled(ctx); err != nil { return nil, err } @@ -856,11 +865,17 @@ func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberR return nil, err } + // 重置密码时同步清空失败计数与锁定:成员多半是因忘记密码连错被锁才找管理员重置, + // 只改哈希不清锁会导致新密码在锁定期内依然登不进(与 admin 端/邮箱自助重置行为对齐)。 + // 注意必须用 map 而非 do 结构体:do 字段为 interface{},nil 表示「未设置」会被跳过, + // locked_until 置 NULL 必须通过 map 显式传递。 _, err = dao.TntUsers.Ctx(ctx). Where("id", memberID). Where("tenant_id", tenantID). - Data(do.TntUsers{ - PasswordHash: passwordHash, + Data(map[string]interface{}{ + "password_hash": passwordHash, + "failed_attempts": 0, + "locked_until": nil, }).Update() if err != nil { return nil, err @@ -909,6 +924,12 @@ func (s *sTenant) UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockRe // GetMember returns a single member's detail. func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) { + // 成员详情含邮箱等 PII,属管理视角数据:member 不得窥视他人信息, + // 自身资料走个人设置(GetProfile) + if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) var user *struct { @@ -948,6 +969,12 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v // GetMemberUsage returns usage statistics for a single member. func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (*v1.TenantMemberUsageRes, error) { + // 成员用量属于成员管理视角的数据:member 角色不得窥视他人消费明细, + // 自身用量走个人工作台/用量日志(已强制过滤本人) + if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) // Verify member exists in tenant @@ -1001,6 +1028,12 @@ func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageR // ListMemberApiKeys returns a paginated list of API keys belonging to a specific member. func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) { + // 同 GetMemberUsage:他人 Key 清单(名称/额度/状态)是管理视角数据, + // member 只管理自己的 Key(个人 Key 列表已强制过滤本人),明文另有属主校验 + if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) page, pageSize := common.NormalizePagination(req.Page, req.PageSize) @@ -1085,12 +1118,13 @@ var memberPeriodLabels = map[string]string{ "month": "按月", } -// formatMemberUSD 格式化金额为 $ 前缀、最多两位小数(去掉末尾 0),与前端 formatMoney(precision: 2) 输出一致 -func formatMemberUSD(v float64) string { +// formatMemberMoney 格式化金额为「本位币符号 + 最多两位小数(去掉末尾 0)」,与前端 formatMoney(precision: 2) 输出一致。 +// 成员额度/消费属 bil_ 记账层,币种 = 系统本位币,符号由调用方一次性取好(billing.CurrencySymbol)避免逐行读配置。 +func formatMemberMoney(sym string, v float64) string { s := strconv.FormatFloat(v, 'f', 2, 64) s = strings.TrimRight(s, "0") s = strings.TrimSuffix(s, ".") - return "$" + s + return sym + s } // ExportMembers exports the tenant member list as CSV or Excel. @@ -1105,6 +1139,10 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR } tenantID := middleware.GetTenantID(ctx) + // 额度/消费属 bil_ 记账层,币种 = 系统本位币;符号与币种码取一次供表头和逐行格式化复用 + moneyCurrency := billing.Currency(ctx) + moneySym := billing.CurrencySymbol(ctx) + columns := []export.Column{ {Field: "username", Header: "用户名"}, {Field: "display_name", Header: "显示名称"}, @@ -1113,7 +1151,7 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR {Field: "status", Header: "状态"}, {Field: "quota", Header: "额度限制"}, {Field: "model_count", Header: "可用模型"}, - {Field: "month_cost", Header: "本月消费(USD)"}, + {Field: "month_cost", Header: "本月消费(" + moneyCurrency + ")"}, {Field: "created_at", Header: "加入时间"}, {Field: "updated_at", Header: "最后更新"}, } @@ -1125,7 +1163,8 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR } return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) { - offset := 0 + // keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行(排序本就是 id DESC) + var cursorID int64 for { model := dao.TntUsers.Ctx(ctx). Where("tenant_id", tenantID) @@ -1136,21 +1175,24 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR if req.Role != "" { model = model.Where("role", req.Role) } + if cursorID > 0 { + model = model.Where("id < ?", cursorID) + } var users []struct { - Id int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` - QuotaType string `json:"quota_type"` - QuotaLimit float64 `json:"quota_limit"` - QuotaPeriod string `json:"quota_period"` - UpdatedAt string `json:"updated_at"` + Id int64 `json:"id" orm:"id"` + Username string `json:"username" orm:"username"` + Email string `json:"email" orm:"email"` + DisplayName string `json:"display_name" orm:"display_name"` + Role string `json:"role" orm:"role"` + Status string `json:"status" orm:"status"` + CreatedAt string `json:"created_at" orm:"created_at"` + QuotaType string `json:"quota_type" orm:"quota_type"` + QuotaLimit float64 `json:"quota_limit" orm:"quota_limit"` + QuotaPeriod string `json:"quota_period" orm:"quota_period"` + UpdatedAt string `json:"updated_at" orm:"updated_at"` } - err := model.OrderDesc("id").Limit(1000).Offset(offset).Scan(&users) + err := model.OrderDesc("id").Limit(1000).Scan(&users) if err = common.IgnoreScanNoRows(err); err != nil { return } @@ -1165,8 +1207,8 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR monthCostMap := make(map[int64]float64) if len(userIds) > 0 { var scopeRows []struct { - UserId int64 `json:"user_id"` - ModelId int64 `json:"model_id"` + UserId int64 `json:"user_id" orm:"user_id"` + ModelId int64 `json:"model_id" orm:"model_id"` } if err = dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). @@ -1202,7 +1244,7 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR // 额度限制:不限制 / 金额(周期性追加周期文案),与列表页渲染一致 quota := "不限制" if u.QuotaType != "" && u.QuotaType != "none" { - quota = formatMemberUSD(u.QuotaLimit) + quota = formatMemberMoney(moneySym, u.QuotaLimit) if u.QuotaType == "periodic" && u.QuotaPeriod != "" { quota += "(" + memberPeriodLabels[u.QuotaPeriod] + ")" } @@ -1221,17 +1263,17 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR "status": memberStatusLabels[u.Status], "quota": quota, "model_count": modelCount, - "month_cost": formatMemberUSD(monthCostMap[u.Id]), + "month_cost": formatMemberMoney(moneySym, monthCostMap[u.Id]), "created_at": u.CreatedAt, "updated_at": u.UpdatedAt, }) { return } + cursorID = u.Id } if len(users) < 1000 { break } - offset += 1000 } }) } diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index b25f3e1..b990b51 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -34,7 +34,14 @@ type ImportResult struct { } // MemberImport parses CSV content, validates, creates an import record. +// +// 角色校验此前缺失:批量导入会直接创建租户成员账号(并按套餐占用席位), +// 与 MemberCreate / MemberInvite 是同一类动作,必须同为 owner/admin。 func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } if err := requireTeamEnabled(ctx); err != nil { return nil, err } diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index e9bfdac..c39acad 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -34,7 +34,7 @@ func (s *sTenant) MemberModelScopes(ctx context.Context, req *v1.TenantMemberMod } var rows []struct { - ModelID int64 `json:"model_id"` + ModelID int64 `json:"model_id" orm:"model_id"` } err = dao.TntMemberModelScopes.Ctx(ctx). Where("tenant_id", tenantID). diff --git a/internal/logic/tenant/member_quota.go b/internal/logic/tenant/member_quota.go index a1c11aa..500ee87 100644 --- a/internal/logic/tenant/member_quota.go +++ b/internal/logic/tenant/member_quota.go @@ -17,6 +17,12 @@ import ( ) func (s *sTenant) MemberQuota(ctx context.Context, req *v1.TenantMemberQuotaReq) (*v1.TenantMemberQuotaRes, error) { + // 成员额度配置是管理员设定的控制线,属管理视角数据:member 不得窥视他人额度, + // 自身额度进度走个人工作台(personal-dashboard)。与 MemberQuotaSet 同门槛。 + if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := middleware.GetTenantID(ctx) var row *struct { diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index d896b4e..20d74e9 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -23,7 +23,7 @@ type pricingTierRow struct { // memberModelScopeNoAccess 表示成员无权访问任何模型的哨兵值 const memberModelScopeNoAccess = -1 -// tenantModelPriceRow 显式分配模型的价格查询结果 +// tenantModelPriceRow 显式分配模型的价格查询结果(平台基础价统一从 pricing JSONB 解析) type tenantModelPriceRow struct { ModelDBID int64 `json:"model_db_id"` ID int64 `json:"id"` @@ -31,12 +31,7 @@ type tenantModelPriceRow struct { PerRequestPrice *float64 `json:"per_request_price"` DiscountRatio *float64 `json:"discount_ratio"` MaxConcurrency *int `json:"max_concurrency"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - BaseCacheReadPrice float64 `json:"base_cache_read_price"` - BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` BaseBillingMode string `json:"base_billing_mode"` - BasePerRequestPrice *float64 `json:"base_per_request_price"` BaseDiscountLabel *string `json:"base_discount_label"` BasePriceChangeNote *string `json:"base_price_change_note"` CustomInputPrice *float64 `json:"custom_input_price"` @@ -44,19 +39,16 @@ type tenantModelPriceRow struct { CustomCacheReadPrice *float64 `json:"custom_cache_read_price"` CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"` CustomPricingTiers string `json:"custom_pricing_tiers"` + BasePricing string `json:"base_pricing"` // pricing JSONB(唯一真相) } // groupPriceRow 分组模型的 base 价格查询结果 type groupPriceRow struct { - ModelID int64 `json:"model_id"` - BaseBillingMode string `json:"base_billing_mode"` - BaseInputPrice float64 `json:"base_input_price"` - BaseOutputPrice float64 `json:"base_output_price"` - BaseCacheReadPrice float64 `json:"base_cache_read_price"` - BaseCacheCreationPrice float64 `json:"base_cache_creation_price"` - BasePerRequestPrice *float64 `json:"base_per_request_price"` - BaseDiscountLabel *string `json:"base_discount_label"` - BasePriceChangeNote *string `json:"base_price_change_note"` + ModelID int64 `json:"model_id"` + BaseBillingMode string `json:"base_billing_mode"` + BaseDiscountLabel *string `json:"base_discount_label"` + BasePriceChangeNote *string `json:"base_price_change_note"` + BasePricing string `json:"base_pricing"` } // baseTierRow 阶梯定价查询结果 @@ -74,7 +66,7 @@ type memberScopeRow struct { ModelName string `json:"model_name"` } -// priceInfo 显式模型的完整价格信息 +// priceInfo 显式模型的完整价格信息(基础价字段从平台 pricing JSONB 解析而来) type priceInfo struct { ID int64 BillingMode *string @@ -94,6 +86,15 @@ type priceInfo struct { CustomCacheReadPrice *float64 CustomCacheCreationPrice *float64 CustomPricingTiers string + BaseBlob *billing.PricingBlob // 平台 pricing JSONB 解析结果(tiered 阶梯/时段/per_second 矩阵) +} + +// BasePerSecondPrices per_second 矩阵(无 blob 时为 nil) +func (p *priceInfo) BasePerSecondPrices() map[string]float64 { + if p.BaseBlob == nil { + return nil + } + return p.BaseBlob.Prices } // groupPriceInfo 分组模型的价格信息 @@ -106,13 +107,39 @@ type groupPriceInfo struct { BasePerRequestPrice *float64 BaseDiscountLabel *string BasePriceChangeNote *string + BaseBlob *billing.PricingBlob +} + +// fillFromBlob 从平台 pricing JSONB 提取展示用基础价(tiered 取首档价,与列表口径一致) +func fillFromBlob(blob *billing.PricingBlob) (input, output, cacheRead, cacheCreation float64, perRequest *float64) { + if blob == nil { + return + } + if len(blob.Tiers) > 0 { + input = blob.Tiers[0].InputPrice + output = blob.Tiers[0].OutputPrice + } + if blob.InputPrice != nil { + input = *blob.InputPrice + } + if blob.OutputPrice != nil { + output = *blob.OutputPrice + } + if blob.CacheReadPrice != nil { + cacheRead = *blob.CacheReadPrice + } + if blob.CacheCreationPrice != nil { + cacheCreation = *blob.CacheCreationPrice + } + perRequest = blob.Price + return } // ListAvailableModels 获取租户可用的模型列表 func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) { tenantID := middleware.GetTenantID(ctx) - models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Search) + models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Vendor, req.Search) if err != nil { return nil, err } @@ -133,31 +160,26 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab if len(explicitDBIDs) > 0 { err = dao.MdlTenantModels.Ctx(ctx). - LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0"). + LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id"). Where("mdl_tenant_models.tenant_id", tenantID). WhereIn("mdl_tenant_models.model_id", explicitDBIDs). - Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, p.per_request_price AS base_per_request_price, p.discount_label AS base_discount_label, p.price_change_note AS base_price_change_note, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). + Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.billing_mode AS base_billing_mode, p.pricing AS base_pricing, p.discount_label AS base_discount_label, p.price_change_note AS base_price_change_note, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers"). Scan(&priceResults) if err != nil { return nil, err } } - // 构建显式模型价格映射 + // 构建显式模型价格映射(基础价从 pricing JSONB 解析) priceMap := make(map[int64]*priceInfo, len(priceResults)) for _, r := range priceResults { - priceMap[r.ModelDBID] = &priceInfo{ + pi := &priceInfo{ ID: r.ID, BillingMode: r.BillingMode, PerRequestPrice: r.PerRequestPrice, DiscountRatio: r.DiscountRatio, MaxConcurrency: r.MaxConcurrency, - BaseInputPrice: r.BaseInputPrice, - BaseOutputPrice: r.BaseOutputPrice, - BaseCacheReadPrice: r.BaseCacheReadPrice, - BaseCacheCreationPrice: r.BaseCacheCreationPrice, BaseBillingMode: r.BaseBillingMode, - BasePerRequestPrice: r.BasePerRequestPrice, BaseDiscountLabel: r.BaseDiscountLabel, BasePriceChangeNote: r.BasePriceChangeNote, CustomInputPrice: r.CustomInputPrice, @@ -165,7 +187,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab CustomCacheReadPrice: r.CustomCacheReadPrice, CustomCacheCreationPrice: r.CustomCacheCreationPrice, CustomPricingTiers: r.CustomPricingTiers, + BaseBlob: billing.ParsePricingBlob(r.BasePricing), } + pi.BaseInputPrice, pi.BaseOutputPrice, pi.BaseCacheReadPrice, pi.BaseCacheCreationPrice, pi.BasePerRequestPrice = + fillFromBlob(pi.BaseBlob) + priceMap[r.ModelDBID] = pi } // 批量查询分组模型的 base 价格 @@ -181,97 +207,53 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab var groupPrices []groupPriceRow err = dao.MdlPricing.Ctx(ctx). WhereIn("model_id", groupDBIDs). - Where("min_tokens", 0). - Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price, discount_label AS base_discount_label, price_change_note AS base_price_change_note"). + Fields("model_id, billing_mode AS base_billing_mode, pricing AS base_pricing, discount_label AS base_discount_label, price_change_note AS base_price_change_note"). Scan(&groupPrices) if err != nil { return nil, err } for _, gp := range groupPrices { - groupPriceMap[gp.ModelID] = &groupPriceInfo{ - BaseBillingMode: gp.BaseBillingMode, - BaseInputPrice: gp.BaseInputPrice, - BaseOutputPrice: gp.BaseOutputPrice, - BaseCacheReadPrice: gp.BaseCacheReadPrice, - BaseCacheCreationPrice: gp.BaseCacheCreationPrice, - BasePerRequestPrice: gp.BasePerRequestPrice, - BaseDiscountLabel: gp.BaseDiscountLabel, - BasePriceChangeNote: gp.BasePriceChangeNote, + gpi := &groupPriceInfo{ + BaseBillingMode: gp.BaseBillingMode, + BaseDiscountLabel: gp.BaseDiscountLabel, + BasePriceChangeNote: gp.BasePriceChangeNote, + BaseBlob: billing.ParsePricingBlob(gp.BasePricing), } + gpi.BaseInputPrice, gpi.BaseOutputPrice, gpi.BaseCacheReadPrice, gpi.BaseCacheCreationPrice, gpi.BasePerRequestPrice = + fillFromBlob(gpi.BaseBlob) + groupPriceMap[gp.ModelID] = gpi } } - // 收集需要查询阶梯定价的模型:显式模型(无自定义阶梯)与分组模型(走 base 阶梯)都纳入 - tieredModelDBIDs := make([]int64, 0) + // 平台阶梯与时段定价:从已解析的 pricing JSONB 提取(tiered 阶梯随 blob.Tiers, + // 时段随 blob.TimeSegments;显式与分组来源统一处理) + baseTiersMap := make(map[int64][]v1.PricingTierItem) + timeSegmentsMap := make(map[int64][]billing.TimeSegment) for _, m := range models { + var blob *billing.PricingBlob if m.Source == "explicit" { if pi, ok := priceMap[m.ModelDBID]; ok { - effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode) - if effectiveBillingMode == "tiered" && pi.CustomPricingTiers == "" { - tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) - } + blob = pi.BaseBlob } - } else { - // 分组模型使用 base 定价,tiered 时同样需要返回阶梯明细 - if gp, ok := groupPriceMap[m.ModelDBID]; ok && gp.BaseBillingMode == "tiered" { - tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID) + } else if gp, ok := groupPriceMap[m.ModelDBID]; ok { + blob = gp.BaseBlob + } + if blob == nil { + continue + } + if len(blob.Tiers) > 0 { + for _, tier := range blob.Tiers { + baseTiersMap[m.ModelDBID] = append(baseTiersMap[m.ModelDBID], v1.PricingTierItem{ + MinTokens: tier.MinTokens, + MaxTokens: tier.MaxTokens, + InputPrice: tier.InputPrice, + OutputPrice: tier.OutputPrice, + }) } } - } - - baseTiersMap := make(map[int64][]v1.PricingTierItem) - if len(tieredModelDBIDs) > 0 { - var baseTiers []baseTierRow - err = dao.MdlPricing.Ctx(ctx). - WhereIn("model_id", tieredModelDBIDs). - Where("billing_mode", "tiered"). - Where("min_tokens > 0"). - Fields("model_id, min_tokens, max_tokens, input_price, output_price"). - OrderAsc("model_id"). - OrderAsc("min_tokens"). - Scan(&baseTiers) - if err != nil { - return nil, err - } - - for _, t := range baseTiers { - baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - InputPrice: t.InputPrice, - OutputPrice: t.OutputPrice, - }) - } - } - - // 时段定价批量查询(锚点行 JSONB;显式与分组来源模型统一处理,展示换算价目用) - allDBIDs := make([]int64, 0, len(models)) - for _, m := range models { - allDBIDs = append(allDBIDs, m.ModelDBID) - } - timeSegmentsMap := make(map[int64][]billing.TimeSegment) - if len(allDBIDs) > 0 { - var segRows []struct { - ModelId int64 `json:"model_id"` - TimeSegments string `json:"time_segments"` - } - if err := dao.MdlPricing.Ctx(ctx). - WhereIn("model_id", allDBIDs). - Where("min_tokens", 0). - WhereNotNull("time_segments"). - Fields("model_id, time_segments"). - Scan(&segRows); err != nil { - return nil, err - } - for _, r := range segRows { - if r.TimeSegments == "" || r.TimeSegments == "null" { - continue - } - var segs []billing.TimeSegment - if err := json.Unmarshal([]byte(r.TimeSegments), &segs); err == nil && len(segs) > 0 { - timeSegmentsMap[r.ModelId] = segs - } + if len(blob.TimeSegments) > 0 { + timeSegmentsMap[m.ModelDBID] = blob.TimeSegments } } @@ -300,6 +282,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, Description: m.Description, @@ -320,9 +303,14 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab if effectiveBillingMode == "tiered" { item.PricingTiers = buildTiers(pi.CustomPricingTiers, pi.BaseInputPrice, pi.BaseOutputPrice, baseTiersMap[m.ModelDBID]) } + // 按秒/特殊计费:矩阵来自平台定价(租户不逐格覆盖,倍率在计费时作用); + // special 的矩阵是输出生成组件的参考单价(素材组件单价在方案配置中) + if effectiveBillingMode == "per_second" || effectiveBillingMode == billing.BillingModeSpecial { + item.PerSecondPrices = pi.BasePerSecondPrices() + } item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], effectiveBillingMode, - inputPrice, outputPrice, perRequestPrice, item.PricingTiers) + inputPrice, outputPrice, perRequestPrice, item.PricingTiers, item.PerSecondPrices) list = append(list, item) } else { @@ -353,6 +341,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, Description: m.Description, @@ -370,8 +359,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab if billingMode == "tiered" && ok { item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID]) } + if (billingMode == "per_second" || billingMode == billing.BillingModeSpecial) && ok && gp.BaseBlob != nil { + item.PerSecondPrices = gp.BaseBlob.Prices + } item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], billingMode, - inputPrice, outputPrice, perRequestPrice, item.PricingTiers) + inputPrice, outputPrice, perRequestPrice, item.PricingTiers, item.PerSecondPrices) list = append(list, item) } } @@ -484,8 +476,13 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab return &v1.TenantAvailableModelsRes{List: list}, nil } -// resolveBillingMode 解析有效计费模式 +// resolveBillingMode 解析有效计费模式。 +// 特殊计费方案模型的模式由平台方案决定(special),租户级覆盖无效 +// (与 billing.GetModelPriceAt 的读侧守卫、admin.UpdateTenantModel 的写侧守卫同口径) func resolveBillingMode(tenantMode *string, baseMode string) string { + if baseMode == billing.BillingModeSpecial { + return billing.BillingModeSpecial + } if tenantMode != nil && *tenantMode != "" { return *tenantMode } @@ -506,8 +503,8 @@ func effectivePrice(custom *float64, base float64) *float64 { return nil } -// buildTiers 组装阶梯定价明细:自定义阶梯优先,否则用 base 首档价格(min_tokens=0)+ 基础阶梯其余档。 -// 显式模型与分组模型共用;baseTiers 为 min_tokens>0 的其余档,为空时仅返回首档。 +// buildTiers 组装阶梯定价明细:自定义阶梯优先,否则用平台阶梯(pricing JSONB tiers 数组,含首档)。 +// 显式模型与分组模型共用;baseTiers 为空时按锚点价合成单档兜底。 func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64, baseTiers []v1.PricingTierItem) []v1.PricingTierItem { if customTiersJSON != "" && customTiersJSON != "null" && customTiersJSON != "[]" { var raw []pricingTierRow @@ -525,29 +522,26 @@ func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64, } } - tiers := make([]v1.PricingTierItem, 0, len(baseTiers)+1) + // 平台阶梯已含首档(min_tokens=0):直接返回 + if len(baseTiers) > 0 { + return baseTiers + } + // 无阶梯数据:按锚点价合成单档 if baseInputPrice > 0 || baseOutputPrice > 0 { - tiers = append(tiers, v1.PricingTierItem{ + return []v1.PricingTierItem{{ MinTokens: 0, MaxTokens: nil, InputPrice: baseInputPrice, OutputPrice: baseOutputPrice, - }) + }} } - if len(baseTiers) > 0 { - if len(tiers) > 0 { - // 首档与第二档衔接:首档 max_tokens = 第二档 min_tokens - tiers[0].MaxTokens = &baseTiers[0].MinTokens - } - tiers = append(tiers, baseTiers...) - } - return tiers + return nil } // buildTimePrices 构建时段展示价目:每个时段 = 模型当前有效价 × 时段乘数(后端换算,前端直接渲染)。 // token 模式换算输入/输出价;per_request 换算按次价;tiered 用首档价换算(起价,前端标注「起」)。 func buildTimePrices(segments []billing.TimeSegment, billingMode string, - inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem) []v1.TimePriceItem { + inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem, perSecondPrices map[string]float64) []v1.TimePriceItem { if len(segments) == 0 { return nil } @@ -561,6 +555,13 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string, tierInput = &tiers[0].InputPrice tierOutput = &tiers[0].OutputPrice } + // per_second / special 模式换算基准:兜底档 "*" 单价,无 "*" 取矩阵最低正价 + var perSecondBase *float64 + if billingMode == "per_second" || billingMode == billing.BillingModeSpecial { + if base := billing.LookupPerSecondPrice(perSecondPrices, "*"); base > 0 { + perSecondBase = &base + } + } result := make([]v1.TimePriceItem, 0, len(segments)) for _, seg := range segments { @@ -578,6 +579,10 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string, if perRequestPrice != nil { tp.PerRequestPrice = mulDisplayPrice(*perRequestPrice, seg.Multiplier) } + case "per_second", billing.BillingModeSpecial: + if perSecondBase != nil { + tp.PerSecondPrice = mulDisplayPrice(*perSecondBase, seg.Multiplier) + } case "tiered": if tierInput != nil { tp.InputPrice = mulDisplayPrice(*tierInput, seg.Multiplier) diff --git a/internal/logic/tenant/model_comparison.go b/internal/logic/tenant/model_comparison.go index 7605b26..96da3ef 100644 --- a/internal/logic/tenant/model_comparison.go +++ b/internal/logic/tenant/model_comparison.go @@ -32,14 +32,22 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe since := gtime.Now().AddDate(0, 0, -req.Days).Format("Y-m-d") - // Build args for parameterized query + // 聚合与趋势两段查询共用同一份 WHERE 和参数(占位符顺序必须一致)。 + // member 角色只能对比自己的用量,避免普通成员看到组织级成本聚合 + // —— 与用量日志的 member 过滤口径一致。 + whereClause := "tenant_id = ? AND created_at >= ?" args := []any{tenantID, since} + if middleware.GetUserRole(ctx) == "member" { + whereClause += " AND user_id = ?" + args = append(args, middleware.GetUserID(ctx)) + } for _, m := range models { args = append(args, m) } // Aggregate per-model stats (parameterized IN clause, no fmt.Sprintf) inClause := strings.Repeat("?,", len(models)-1) + "?" + whereClause += " AND model_name IN (" + inClause + ")" aggQuery := ` SELECT model_name, @@ -51,7 +59,7 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens FROM bil_usage_logs - WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `) + WHERE ` + whereClause + ` GROUP BY model_name ` @@ -132,8 +140,8 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe items[i].IsRecommended = items[i].ModelName == recommended } - // Daily trends - trends := fetchTrends(ctx, tenantID, since, models, args) + // Daily trends(与主查询共用 WHERE/参数,member 过滤口径一致) + trends := fetchTrends(whereClause, args) return &v1.ModelComparisonRes{ Summary: v1.ModelComparisonSummary{ @@ -209,8 +217,7 @@ func recommendBest(items []v1.ModelComparisonItem) (string, string) { return best, fmt.Sprintf("综合评分 %.1f(费用40%% + 延迟30%% + 成功率30%%)", bestScore) } -func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, baseArgs []any) []v1.ModelTrendDay { - inClause := strings.Repeat("?,", len(models)-1) + "?" +func fetchTrends(whereClause string, args []any) []v1.ModelTrendDay { trendQuery := ` SELECT DATE(created_at) as day, @@ -219,12 +226,12 @@ func fetchTrends(ctx context.Context, tenantID int64, since string, models []str COALESCE(SUM(total_cost), 0) as cost, COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency FROM bil_usage_logs - WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `) + WHERE ` + whereClause + ` GROUP BY DATE(created_at), model_name ORDER BY day ` - records, err := g.DB().Raw(trendQuery, baseArgs...).All() + records, err := g.DB().Raw(trendQuery, args...).All() if err != nil { return nil } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index f3a204c..d0482c0 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -68,6 +68,27 @@ func getOAuthSetting(ctx context.Context, key string) string { return common.Config().GetOption(ctx, key) } +// getOAuthRedirectBase 返回构造 OAuth redirect_uri 的基址。 +// 优先级:site_url(管理后台「基础配置-站点后端地址」,应与 GitHub/Google 应用后台 +// 登记的回调域名一致)> 按当前 API 请求自身 Host 推断。 +// 授权跳转与 token 交换两个阶段的 redirect_uri 必须完全一致(OAuth 规范强制校验), +// 因此兜底取后端 API 请求的 Host 而非前端 Origin——授权与回调都是打向后端的请求, +// 同一部署下 Host 推断结果天然一致。 +func getOAuthRedirectBase(ctx context.Context) string { + base := strings.TrimRight(getOAuthSetting(ctx, "site_url"), "/") + if base != "" { + return base + } + if r := g.RequestFromCtx(ctx); r != nil { + scheme := "https" + if r.GetHeader("X-Forwarded-Proto") == "http" || strings.HasPrefix(r.GetHost(), "localhost") || strings.HasPrefix(r.GetHost(), "127.0.0.1") { + scheme = "http" + } + return fmt.Sprintf("%s://%s", scheme, r.GetHost()) + } + return "" +} + // encryptOAuthToken encrypts a token string for secure storage. func encryptOAuthToken(ctx context.Context, token string) string { if token == "" { @@ -113,10 +134,7 @@ func (s *sTenant) GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthori return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用") } - siteURL := getOAuthSetting(ctx, "site_url") - if siteURL == "" { - siteURL = "http://localhost:3000" - } + siteURL := getOAuthRedirectBase(ctx) redirectURI := siteURL + fmt.Sprintf("/api/tenant/oauth/%s/callback", req.Provider) state := generateState() @@ -318,6 +336,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) ( if err != nil { return nil, err } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) // 生成 JWT token pair tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", user.Role, tenantID, sessionID, jti) @@ -599,10 +618,7 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth v.Set("code", code) v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id")) v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret")) - siteURL := getOAuthSetting(ctx, "site_url") - if siteURL == "" { - siteURL = "http://localhost:3000" - } + siteURL := getOAuthRedirectBase(ctx) v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback") v.Set("grant_type", "authorization_code") diff --git a/internal/logic/tenant/open_platform.go b/internal/logic/tenant/open_platform.go index f406547..71c7ebc 100644 --- a/internal/logic/tenant/open_platform.go +++ b/internal/logic/tenant/open_platform.go @@ -27,6 +27,45 @@ import ( // 开放平台应用管理 // ============================================================ +// openAppPermissionPoints 是开放平台应用可授予的全部权限点。 +// +// 与 internal/logic/open 中各 handler 实际校验的字符串一一对应(models:read 目前尚无 +// handler 使用,保留以免存量授权在编辑时被判为非法)。此前创建/更新只校验 required, +// 任意字符串都能入库:判定是精确匹配且 fail-closed,写错不会越权,但会静默失效 —— +// 写成 members:wirte 的应用在任何接口上都拿不到权限,且排查时毫无线索。 +var openAppPermissionPoints = map[string]bool{ + "members:read": true, + "members:write": true, + "keys:read": true, + "keys:write": true, + "projects:read": true, + "projects:write": true, + "models:read": true, + "usage:read": true, + "billing:read": true, +} + +// sanitizeOpenAppPermissions 校验并规整应用权限点:去重去空、拒绝未定义的权限点。 +func sanitizeOpenAppPermissions(perms []string) ([]string, error) { + seen := make(map[string]bool, len(perms)) + out := make([]string, 0, len(perms)) + for _, p := range perms { + p = strings.TrimSpace(p) + if p == "" || seen[p] { + continue + } + if !openAppPermissionPoints[p] { + return nil, common.NewBadRequestError("无效的权限点:" + p) + } + seen[p] = true + out = append(out, p) + } + if len(out) == 0 { + return nil, common.NewBadRequestError("请至少选择一个权限点") + } + return out, nil +} + type opnAppEncryptedSecretData struct { EncryptedSecret any `json:"encrypted_secret"` } @@ -71,7 +110,6 @@ func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1. AppID: app.AppId, Permissions: perms, Status: app.Status, - IsSandbox: app.IsSandbox, RateLimit: app.RateLimit, } if app.LastUsedAt != nil { @@ -138,7 +176,11 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) ( return nil, err } - permsJSON, _ := json.Marshal(req.Permissions) + perms, err := sanitizeOpenAppPermissions(req.Permissions) + if err != nil { + return nil, err + } + permsJSON, _ := json.Marshal(perms) ipJSON, _ := json.Marshal(req.IPWhitelist) if req.IPWhitelist == nil { ipJSON = []byte("[]") @@ -203,7 +245,11 @@ func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) ( hasUpdate = true } if req.Permissions != nil { - permsJSON, _ := json.Marshal(req.Permissions) + perms, err := sanitizeOpenAppPermissions(req.Permissions) + if err != nil { + return nil, err + } + permsJSON, _ := json.Marshal(perms) data.Permissions = string(permsJSON) hasUpdate = true } diff --git a/internal/logic/tenant/open_platform_test.go b/internal/logic/tenant/open_platform_test.go new file mode 100644 index 0000000..f185287 --- /dev/null +++ b/internal/logic/tenant/open_platform_test.go @@ -0,0 +1,79 @@ +package tenant + +import ( + "os" + "regexp" + "sort" + "testing" +) + +// checkOpenPermissionCall 匹配 open 层 handler 里的 middleware.CheckOpenPermission(ctx, "xxx:yyy")。 +var checkOpenPermissionCall = regexp.MustCompile(`CheckOpenPermission\(ctx,\s*"([^"]+)"\)`) + +// TestOpenAppPermissionWhitelistCoversHandlers 保证「可授予的权限点」覆盖「实际被校验的权限点」。 +// +// 两者分处两个包:白名单在 tenant(配置侧),校验在 open(执行侧)。若有人新增一个 +// CheckOpenPermission("reports:read") 却忘了加进白名单,租户将永远无法把该权限授予应用 —— +// 接口对所有应用恒 403,且因为 sanitizeOpenAppPermissions 会把它判为「无效的权限点」, +// 连手工配置这条退路也没有。这类漂移不会有编译错误,只能靠测试兜住。 +func TestOpenAppPermissionWhitelistCoversHandlers(t *testing.T) { + src, err := os.ReadFile("../open/open_api.go") + if err != nil { + t.Fatalf("读取 open 层源码失败: %v", err) + } + + matches := checkOpenPermissionCall.FindAllStringSubmatch(string(src), -1) + if len(matches) == 0 { + t.Fatal("未在 open 层源码中匹配到任何 CheckOpenPermission 调用,正则可能已失效") + } + + seen := make(map[string]bool) + var missing []string + for _, m := range matches { + perm := m[1] + if seen[perm] { + continue + } + seen[perm] = true + if !openAppPermissionPoints[perm] { + missing = append(missing, perm) + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("以下权限点被 handler 校验但不在 openAppPermissionPoints 白名单中,租户无法授予:%v", missing) + } +} + +func TestSanitizeOpenAppPermissions(t *testing.T) { + t.Run("去重去空并保留合法权限点", func(t *testing.T) { + got, err := sanitizeOpenAppPermissions([]string{"members:read", " ", "members:read", "usage:read"}) + if err != nil { + t.Fatalf("预期通过,实际报错: %v", err) + } + want := []string{"members:read", "usage:read"} + if len(got) != len(want) { + t.Fatalf("预期 %v,实际 %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("预期 %v,实际 %v", want, got) + } + } + }) + + t.Run("拒绝未定义的权限点", func(t *testing.T) { + // 拼错一个字母不会有编译错误,但会让该应用在对应接口上永久失效 + if _, err := sanitizeOpenAppPermissions([]string{"members:wirte"}); err == nil { + t.Fatal("预期拒绝拼写错误的权限点,实际通过") + } + }) + + t.Run("拒绝空集合", func(t *testing.T) { + // 零权限的应用调用任何接口都是 403,配置时就该报错而不是留一个静默失效的应用 + if _, err := sanitizeOpenAppPermissions([]string{}); err == nil { + t.Fatal("预期拒绝空权限集合,实际通过") + } + }) +} diff --git a/internal/logic/tenant/playground.go b/internal/logic/tenant/playground.go deleted file mode 100644 index 1507fc2..0000000 --- a/internal/logic/tenant/playground.go +++ /dev/null @@ -1,104 +0,0 @@ -package tenant - -import ( - "context" - "fmt" - "time" - - "github.com/gogf/gf/v2/frame/g" - - v1 "github.com/qianfree/team-api/api/tenant/v1" - "github.com/qianfree/team-api/internal/logic/common" - "github.com/qianfree/team-api/internal/middleware" -) - -// ============================================================ -// Sandbox(模拟调用,不计费) -// ============================================================ - -func (s *sTenant) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) { - tenantID := middleware.GetTenantID(ctx) - - now := time.Now() - quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601")) - remaining, err := g.Redis().Do(ctx, "GET", quotaKey) - if err != nil { - return nil, err - } - - defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int() - if defaultQuota <= 0 { - defaultQuota = 100 - } - - remainInt := defaultQuota - if !remaining.IsNil() && !remaining.IsEmpty() { - remainInt = remaining.Int() - } - - if remainInt <= 0 { - return nil, common.NewBusinessError(10056, "本月沙箱额度已用完") - } - - _, err = g.Redis().Do(ctx, "DECR", quotaKey) - if err != nil { - return nil, err - } - if remainInt == defaultQuota { - _, _ = g.Redis().Do(ctx, "EXPIRE", quotaKey, 86400*30) - } - - content := generateSimulatedResponse(req.Model, req.Messages) - - return &v1.SandboxChatRes{ - Content: content, - IsSandbox: true, - RemainingQuota: remainInt - 1, - }, nil -} - -func (s *sTenant) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) { - tenantID := middleware.GetTenantID(ctx) - - defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int() - if defaultQuota <= 0 { - defaultQuota = 100 - } - - now := time.Now() - quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601")) - remaining, err := g.Redis().Do(ctx, "GET", quotaKey) - if err != nil { - return nil, err - } - - remainInt := defaultQuota - if !remaining.IsNil() && !remaining.IsEmpty() { - remainInt = remaining.Int() - } - - used := defaultQuota - remainInt - if used < 0 { - used = 0 - } - - return &v1.SandboxQuotaRes{ - TotalQuota: defaultQuota, - RemainingQuota: remainInt, - UsedQuota: used, - }, nil -} - -func generateSimulatedResponse(model string, messages []v1.PlaygroundMessage) string { - userMsg := "" - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "user" { - userMsg = messages[i].Content - break - } - } - if userMsg == "" { - userMsg = "你的请求" - } - return fmt.Sprintf("这是来自模型 %s 的模拟响应。您发送的消息是:%q\n\n(沙箱模式:此响应为模拟数据,不会产生实际费用)", model, userMsg) -} diff --git a/internal/logic/tenant/plugin.go b/internal/logic/tenant/plugin.go index add1c79..2f659f8 100644 --- a/internal/logic/tenant/plugin.go +++ b/internal/logic/tenant/plugin.go @@ -5,6 +5,7 @@ import ( v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/plugin" @@ -76,7 +77,23 @@ func (s *sTenant) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDe }, nil } +// assertPluginManager 校验插件的写操作权限。 +// +// 插件配置与启停是【组织级】设置:一次改动对整个租户的全部成员生效,配置里还可能包含 +// 第三方凭据。此前三个写接口只取了 tenantID 就直接落库,任意 member 都能改掉或关停 +// 全组织的插件,与 List/Detail(读)同权。读保持全员可见,写收敛到 owner/admin。 +func assertPluginManager(ctx context.Context) error { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return common.NewForbiddenError("需要 owner 或 admin 权限") + } + return nil +} + func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.UpdatePluginConfigForTenant(ctx, req.Name, tenantID, req.Config); err != nil { return nil, err @@ -85,6 +102,9 @@ func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPl } func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEnableReq) (*v1.TenantPluginEnableRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.EnableForTenant(ctx, req.Name, tenantID); err != nil { return nil, err @@ -93,6 +113,9 @@ func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEn } func (s *sTenant) TenantPluginDisable(ctx context.Context, req *v1.TenantPluginDisableReq) (*v1.TenantPluginDisableRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.DisableForTenant(ctx, req.Name, tenantID); err != nil { return nil, err diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index 56e45d1..d287403 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -355,7 +355,14 @@ func (s *sTenant) ProjectArchive(ctx context.Context, req *v1.TenantProjectArchi } // ProjectUnarchive restores an archived project. Keys are NOT auto-restored. +// +// 角色校验此前缺失:Create/Update/Archive 都要求 owner/admin,唯独恢复归档没拦, +// 任意 member 都能把管理员刚归档的项目恢复回来(归档是治理动作,撤销它同样是)。 func (s *sTenant) ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } tenantID := middleware.GetTenantID(ctx) var project *struct { @@ -392,9 +399,9 @@ func (s *sTenant) ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUna func CheckBudgetExhausted(ctx context.Context) error { // Find active projects with budget set var projects []struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - Budget float64 `json:"budget"` + ID int64 `json:"id" orm:"id"` + TenantID int64 `json:"tenant_id" orm:"tenant_id"` + Budget float64 `json:"budget" orm:"budget"` } err := dao.TntProjects.Ctx(ctx). Where("status", "active"). diff --git a/internal/logic/tenant/security.go b/internal/logic/tenant/security.go index 7074031..58f6fa0 100644 --- a/internal/logic/tenant/security.go +++ b/internal/logic/tenant/security.go @@ -67,6 +67,7 @@ func (s *sTenant) Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v if err != nil { return nil, gerror.Wrapf(err, "create session") } + refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID) tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, user.TenantId, sessionID, jti) if err != nil { diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 0f40bf7..300d8ba 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -10,6 +10,7 @@ import ( v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/utility/export" @@ -225,8 +226,14 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) "kling": "Kling", "midjourney": "Midjourney", "suno": "Suno", + "volcengine": "火山引擎", + "ali": "阿里", + "minimax": "MiniMax", } + // 预扣/实际费用属 bil_ 记账层,币种 = 系统本位币 + moneyCurrency := billing.Currency(ctx) + columns := []export.Column{ {Field: "public_task_id", Header: "任务ID"}, {Field: "platform_name", Header: "平台"}, @@ -235,8 +242,8 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) {Field: "model_name", Header: "模型"}, {Field: "progress", Header: "进度"}, {Field: "username", Header: "用户"}, - {Field: "pre_deduct_amount", Header: "预扣金额(USD)"}, - {Field: "actual_cost", Header: "实际费用(USD)"}, + {Field: "pre_deduct_amount", Header: "预扣金额(" + moneyCurrency + ")"}, + {Field: "actual_cost", Header: "实际费用(" + moneyCurrency + ")"}, {Field: "billing_settled", Header: "是否已结算"}, {Field: "fail_reason", Header: "失败原因"}, {Field: "submit_time", Header: "提交时间"}, diff --git a/internal/logic/tenant/ticket.go b/internal/logic/tenant/ticket.go index 1268d45..3fc64f9 100644 --- a/internal/logic/tenant/ticket.go +++ b/internal/logic/tenant/ticket.go @@ -47,7 +47,7 @@ func (s *sTenant) TicketList(ctx context.Context, req *v1.TenantTicketListReq) ( items := make([]*v1.TenantTicketItem, 0) listQuery := dao.SptTickets.Ctx(ctx). LeftJoin("sys_admin_users sa", "spt_tickets.assigned_admin_id = sa.id"). - Fields("spt_tickets.*, COALESCE(sa.username, '') as assigned_admin_name"). + Fields("spt_tickets.*, COALESCE(sa.display_name, '') as assigned_admin_name"). Where("spt_tickets.tenant_id", tenantID). Where("spt_tickets.user_id", userID) if req.Status != "" { @@ -92,7 +92,7 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v var ticket *ticketRow err := dao.SptTickets.Ctx(ctx). LeftJoin("sys_admin_users sa", "spt_tickets.assigned_admin_id = sa.id"). - Fields("spt_tickets.*, COALESCE(sa.username, '') as assigned_admin_name"). + Fields("spt_tickets.*, COALESCE(sa.display_name, '') as assigned_admin_name"). Where("spt_tickets.id", req.Id). Where("spt_tickets.tenant_id", tenantID). Where("spt_tickets.user_id", userID). @@ -104,15 +104,36 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v return nil, common.NewNotFoundError("工单") } - var replyRows []*v1.TenantTicketReplyItem + // 回复按 user_type 分别关联管理员/租户用户表取作者名 + type replyRow struct { + entity.SptReplies + AuthorName string `json:"author_name" orm:"author_name"` + } + var replyRows []*replyRow err = dao.SptReplies.Ctx(ctx). - Where("ticket_id", req.Id). - OrderAsc("created_at"). + LeftJoin("sys_admin_users sa", "spt_replies.user_type = 'admin' AND spt_replies.user_id = sa.id"). + LeftJoin("tnt_users tu", "spt_replies.user_type = 'tenant' AND spt_replies.user_id = tu.id"). + Fields("spt_replies.*, CASE WHEN spt_replies.user_type = 'admin' THEN COALESCE(sa.display_name, '') ELSE COALESCE(tu.display_name, '') END as author_name"). + Where("spt_replies.ticket_id", req.Id). + OrderAsc("spt_replies.created_at"). Scan(&replyRows) if err != nil { return nil, err } + replies := make([]*v1.TenantTicketReplyItem, 0, len(replyRows)) + for _, r := range replyRows { + replies = append(replies, &v1.TenantTicketReplyItem{ + Id: r.Id, + TicketId: r.TicketId, + UserId: r.UserId, + UserType: r.UserType, + AuthorName: r.AuthorName, + Content: r.Content, + CreatedAt: r.CreatedAt, + }) + } + var attachRows []*entity.SptAttachments err = dao.SptAttachments.Ctx(ctx). Where("ticket_id", req.Id). @@ -149,7 +170,7 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v AssignedAdminName: ticket.AssignedAdminName, CreatedAt: ticket.CreatedAt, UpdatedAt: ticket.UpdatedAt, - Replies: replyRows, + Replies: replies, Attachments: attachments, }, nil } @@ -282,7 +303,7 @@ func (s *sTenant) ExportTickets(ctx context.Context, req *v1.TenantTicketExportR buildQuery := func() *gdb.Model { query := dao.SptTickets.Ctx(ctx). LeftJoin("sys_admin_users sa", "spt_tickets.assigned_admin_id = sa.id"). - Fields("spt_tickets.id, spt_tickets.category, spt_tickets.title, spt_tickets.urgency, spt_tickets.status, COALESCE(sa.username, '') as assigned_admin_name, spt_tickets.created_at"). + Fields("spt_tickets.id, spt_tickets.category, spt_tickets.title, spt_tickets.urgency, spt_tickets.status, COALESCE(sa.display_name, '') as assigned_admin_name, spt_tickets.created_at"). Where("spt_tickets.tenant_id", tenantID). Where("spt_tickets.user_id", userID) if req.Status != "" { diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 1e2beab..669d9f0 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -77,11 +77,15 @@ func AdminAuth(r *ghttp.Request) { return } - // Verify user still exists and is active + // Verify user still exists and is active. + // role 一并取出:super_admin 会短路整套鉴权(HasPermission 直接放行、 + // assertSuperAdmin 与 superAdminOnlyRules 都只看它),是全站最高权级的判据, + // 因此必须以数据库为准,不能沿用签发时写进 token 的 claims.Role —— 否则给某人 + // 临时提为超管再降回来后,其旧 token 在有效期内仍是超管(降权无法即时生效)。 var user *entity.SysAdminUsers err = dao.SysAdminUsers.Ctx(r.Context()). Where("id", claims.UserID). - Fields("status"). + Fields("status, role"). Scan(&user) if err != nil { response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) @@ -95,7 +99,7 @@ func AdminAuth(r *ghttp.Request) { // Set auth context r.SetCtxVar(CtxKeyUserID, claims.UserID) r.SetCtxVar(CtxKeyUserType, claims.UserType) - r.SetCtxVar(CtxKeyRole, claims.Role) + r.SetCtxVar(CtxKeyRole, user.Role) r.SetCtxVar(CtxKeySessionID, claims.SessionID) r.SetCtxVar(CtxKeyJti, claims.ID) diff --git a/internal/middleware/body_limit.go b/internal/middleware/body_limit.go new file mode 100644 index 0000000..8d24021 --- /dev/null +++ b/internal/middleware/body_limit.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gfile" +) + +// GoFrame 未配置 clientMaxBodySize 时的默认请求体上限(与 ghttp 内部默认一致) +const defaultClientMaxBodySize int64 = 8 * 1024 * 1024 + +var ( + relayBodyLimitOnce sync.Once + relayBodyLimitBytes int64 +) + +// relayClientMaxBodySize 读取 server.clientMaxBodySize 配置,与 GoFrame Server +// 使用完全相同的键和解析方式(gfile.StrToSize,支持 "32MB" 等写法),保证预检阈值 +// 与 http.MaxBytesReader 实际生效的上限一致,不会出现预检放过、实际读取仍 500 的情况。 +// 未配置或解析失败时回退到 GoFrame 默认 8MB。 +func relayClientMaxBodySize() int64 { + relayBodyLimitOnce.Do(func() { + relayBodyLimitBytes = defaultClientMaxBodySize + if v, err := g.Cfg().Get(context.Background(), "server.clientMaxBodySize"); err == nil && v != nil { + if s := v.String(); s != "" { + if n := gfile.StrToSize(s); n > 0 { + relayBodyLimitBytes = n + } + } + } + }) + return relayBodyLimitBytes +} + +// RelayBodyLimit 在读取请求体之前按 Content-Length 预检大小,超限直接返回 413。 +// +// 背景:GoFrame 会用 http.MaxBytesReader 包装请求体,超限时 r.GetBody() 在 +// MakeBodyRepeatableRead 里直接 panic,被框架中间件链捕获后客户端只会收到 +// 裸的 500 "Internal Error"。此中间件把超限请求转为带明确语义的 413 + 协议原生 +// 错误格式(/v1/messages 走 Claude 格式,其余走 OpenAI 格式),并省去鉴权后的 +// 内容过滤等无效工作。 +// +// 局限:无 Content-Length 的分块传输无法预检,仍会走 GoFrame 的 500 兜底(罕见, +// 主流 SDK 均携带 Content-Length)。 +func RelayBodyLimit(r *ghttp.Request) { + limit := relayClientMaxBodySize() + if r.ContentLength <= limit { + r.Middleware.Next() + return + } + + g.Log().Warningf(r.Context(), + "[RelayBodyLimit] 请求体超限已拒绝: path=%s, content_length=%d, limit=%d, client_ip=%s, ua=%s", + r.URL.Path, r.ContentLength, limit, r.GetClientIp(), r.Header.Get("User-Agent")) + + // 先排空再响应:客户端此时往往还在上传(本例中 ZCode 上传耗时 53s), + // 若不等上传完成就抢先返回并关连接,413 响应体会在半路被掐掉,客户端 + // 只能看到连接重置。r.Body 已被 http.MaxBytesReader 封顶在上限值, + // 排空量有界(超限后读返回错误即停),不会被恶意大包无限消耗带宽。 + _, _ = io.Copy(io.Discard, r.Body) + + writeBodyTooLargeError(r, limit) +} + +// writeBodyTooLargeError 按端点协议写出 413 错误响应。 +// /v1/messages 系列使用 Claude 格式,其余端点使用 OpenAI 格式。 +func writeBodyTooLargeError(r *ghttp.Request, limit int64) { + r.Response.Header().Set("Content-Type", "application/json") + r.Response.WriteHeader(http.StatusRequestEntityTooLarge) + + // 与 Anthropic 官方 413 错误类型对齐 + msg := "请求体过大,单次请求最大允许 " + gfile.FormatSize(limit) + + var body []byte + if strings.HasPrefix(r.URL.Path, "/v1/messages") { + body, _ = json.Marshal(map[string]any{ + "type": "error", + "error": map[string]string{ + "type": "request_too_large", + "message": msg, + }, + }) + } else { + body, _ = json.Marshal(map[string]any{ + "error": map[string]string{ + "type": "invalid_request_error", + "message": msg, + }, + }) + } + r.Response.Write(body) +} diff --git a/internal/middleware/body_limit_test.go b/internal/middleware/body_limit_test.go new file mode 100644 index 0000000..0bb5148 --- /dev/null +++ b/internal/middleware/body_limit_test.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/test/gtest" +) + +// startBodyLimitServer 起一个只挂 RelayBodyLimit 中间件的测试服务。 +// 通过 once 钩子把阈值压到 1KB,方便用普通请求体构造超限场景, +// 真实生效路径(Content-Length 比较 + 原生格式错误响应)与生产完全一致。 +func startBodyLimitServer(t *gtest.T, name string) (*ghttp.Server, *gclient.Client) { + relayBodyLimitOnce.Do(func() { relayBodyLimitBytes = 1024 }) + s := g.Server(name) + s.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(RelayBodyLimit) + group.POST("/echo", func(r *ghttp.Request) { + r.Response.Write("ok") + }) + group.POST("/v1/messages", func(r *ghttp.Request) { + r.Response.Write("ok") + }) + }) + s.SetAddr("127.0.0.1:0") + s.SetDumpRouterMap(false) + s.Start() + + client := g.Client() + client.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + return s, client +} + +// TestRelayBodyLimit_PassThrough 未超限请求必须原样到达业务处理器 +func TestRelayBodyLimit_PassThrough(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + s, client := startBodyLimitServer(t, "relay-body-limit-pass") + defer s.Shutdown() + + resp, err := client.Post(context.Background(), "/echo", strings.Repeat("a", 64)) + t.AssertNil(err) + defer resp.Close() + + t.Assert(resp.StatusCode, 200) + }) +} + +// TestRelayBodyLimit_RejectOpenAIFormat 超限请求返回 413 + OpenAI 格式错误 +func TestRelayBodyLimit_RejectOpenAIFormat(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + s, client := startBodyLimitServer(t, "relay-body-limit-openai") + defer s.Shutdown() + + resp, err := client.Post(context.Background(), "/echo", strings.Repeat("a", 4096)) + t.AssertNil(err) + defer resp.Close() + + t.Assert(resp.StatusCode, 413) + body := resp.ReadAllString() + t.Assert(strings.Contains(body, `"invalid_request_error"`), true) + t.Assert(strings.Contains(body, "请求体过大"), true) + }) +} + +// TestRelayBodyLimit_RejectClaudeFormat /v1/messages 超限请求返回 413 + Claude 格式错误 +func TestRelayBodyLimit_RejectClaudeFormat(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + s, client := startBodyLimitServer(t, "relay-body-limit-claude") + defer s.Shutdown() + + resp, err := client.Post(context.Background(), "/v1/messages", strings.Repeat("a", 4096)) + t.AssertNil(err) + defer resp.Close() + + t.Assert(resp.StatusCode, 413) + body := resp.ReadAllString() + t.Assert(strings.Contains(body, `"request_too_large"`), true) + }) +} diff --git a/internal/middleware/content_filter.go b/internal/middleware/content_filter.go index 458f63a..b86f645 100644 --- a/internal/middleware/content_filter.go +++ b/internal/middleware/content_filter.go @@ -94,7 +94,12 @@ func ContentFilter(r *ghttp.Request) { return } - result := common.ContentFilter().Check(body) + // 多模态请求体里的 base64 内联媒体先剥离再匹配:几 MB 的 base64 白扫一遍纯属浪费, + // 且 base64 字母表可能恰好拼出敏感词,导致正常的带图请求被误 block。 + // 纯文本请求体原样返回,不额外拷贝。 + checkBody := common.StripInlineMediaBytes(body) + + result := common.ContentFilter().Check(checkBody) if !result.Matched { r.Middleware.Next() return @@ -105,7 +110,7 @@ func ContentFilter(r *ghttp.Request) { r.SetCtxVar(CtxKeyContentFilterWords, result.MatchedWords) // Queue async log write - queueContentFilterLog(r, mode, result.MatchedWords, string(body)) + queueContentFilterLog(r, mode, result.MatchedWords, string(checkBody)) switch mode { case "log": @@ -115,8 +120,10 @@ func ContentFilter(r *ghttp.Request) { case "replace": g.Log().Infof(r.Context(), "[ContentFilter] replaced words: %v", result.MatchedWords) r.SetCtxVar(CtxKeyContentFilterReplaced, true) - r.SetCtxVar(CtxKeyContentFilterOriginal, string(body)) - r.SetCtxVar(CtxKeyContentFilterFiltered, result.FilteredText) + r.SetCtxVar(CtxKeyContentFilterOriginal, string(checkBody)) + // 替换后的请求体必须作用在原始 body 上——剥离版本丢了内联媒体, + // 一旦被下游拿去改写上游请求就会把图片/音频弄没。命中才多跑这一次,代价可接受。 + r.SetCtxVar(CtxKeyContentFilterFiltered, common.ContentFilter().Check(body).FilteredText) r.Middleware.Next() case "block": diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go index 9a7bc6e..3903992 100644 --- a/internal/middleware/idempotency.go +++ b/internal/middleware/idempotency.go @@ -43,13 +43,24 @@ func Idempotency(r *ghttp.Request) { tenantID := r.GetCtxVar(CtxKeyTenantID).Int64() userID := r.GetCtxVar(CtxKeyUserID).Int64() userType := r.GetCtxVar(CtxKeyUserType).String() + + // 开放平台不走 TenantAuth,身份存在 OpenPlatformAuth 注入的 typed context key 里, + // 上面三个 CtxVar 全是零值。若不单独取,所有应用会共用同一个 ":0:0:" 前缀 —— + // 不同租户复用同一个 Idempotency-Key 就会读到彼此缓存的响应体(跨租户数据泄漏)。 + // 主体取「应用」而非「用户」:开放平台请求由应用发起,重试也以应用为单位。 + if appID := GetOpenAppID(r.Context()); appID > 0 { + userType = "open" + tenantID = GetOpenTenantID(r.Context()) + userID = appID + } + idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey) // Check if this key has been processed before var record struct { - Id int64 `json:"id"` - Status string `json:"status"` - ResponseBody string `json:"response_body"` + Id int64 `json:"id" orm:"id"` + Status string `json:"status" orm:"status"` + ResponseBody string `json:"response_body" orm:"response_body"` } err := dao.SysIdempotencyRecords.Ctx(ctx). diff --git a/internal/middleware/open_auth.go b/internal/middleware/open_auth.go index 7f55e8b..94fb1f3 100644 --- a/internal/middleware/open_auth.go +++ b/internal/middleware/open_auth.go @@ -38,7 +38,6 @@ const ( ctxKeyOpenAppID openContextKey = "openAppId" ctxKeyOpenTenantID openContextKey = "openTenantId" ctxKeyOpenPermissions openContextKey = "openPermissions" - ctxKeyOpenIsSandbox openContextKey = "openIsSandbox" ) var openAppCache = lcommon.NewCache("open_app", 60*time.Second) @@ -90,6 +89,15 @@ func OpenPlatformAuth(r *ghttp.Request) { return } + // 租户状态校验:应用只是租户的一条独立入口,租户被停服/销户后必须一并失效。 + // 控制台走 ValidateTenantPrincipal、relay 走 ValidateApiKey,两处都会拦下停服租户; + // 开放平台此前是唯一缺口 —— 停服后仍可继续建成员、建 Key、读账单,停服动作形同虚设。 + // 不加缓存:这是一次主键查询,而开放平台是低频管理接口,缓存只会换来「停服延迟生效」的窗口。 + if !isOpenTenantActive(r.Context(), app.TenantId) { + response.Error(r, consts.ErrTenantSuspended) + return + } + // Check IP whitelist if err := checkOpenAppIPWhitelist(r, app); err != nil { response.Error(r, err) @@ -132,12 +140,31 @@ func OpenPlatformAuth(r *ghttp.Request) { ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id) ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId) ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions) - ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox) r.SetCtx(ctx) r.Middleware.Next() } +// isOpenTenantActive 校验应用所属租户处于 active 状态。 +// 查询失败或租户不存在时一律 fail-closed —— 鉴权环节宁可误拒,不可误放。 +func isOpenTenantActive(ctx context.Context, tenantID int64) bool { + if tenantID <= 0 { + return false + } + var tenant *struct { + Status string `json:"status"` + } + err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID). + Fields("status"). + Scan(&tenant) + if err = lcommon.IgnoreScanNoRows(err); err != nil { + g.Log().Warningf(ctx, "[OpenAuth] tenant status lookup failed tenant=%d: %v", tenantID, err) + return false + } + return tenant != nil && tenant.Status == "active" +} + // checkOpenAppNonce enforces replay protection by recording each nonce in Redis. // 用 SET NX 原子写入 nonce:首次出现返回 OK 放行;已存在说明是重放,写 401 并返回 false。 // Redis 故障时 fail-open(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间 diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go index 701d6f1..6499437 100644 --- a/internal/middleware/operation_log.go +++ b/internal/middleware/operation_log.go @@ -90,12 +90,19 @@ func OperationLog(r *ghttp.Request) { return } - // Get auth info + // Get auth info;开放平台请求没有用户身份,以应用身份(open_app + 应用ID)留痕, + // 否则 /api/open 的建成员/建删 Key 等写操作在三张审计表里都不存在 userID := GetUserID(r.Context()) userType := GetUserType(r.Context()) - + tenantID := GetTenantID(r.Context()) if userID == 0 { - return + if appID := GetOpenAppID(r.Context()); appID > 0 { + userID = appID + userType = "open_app" + tenantID = GetOpenTenantID(r.Context()) + } else { + return + } } // Parse resource info from URL path @@ -134,7 +141,6 @@ func OperationLog(r *ghttp.Request) { logData.ResourceId = resourceID } - tenantID := GetTenantID(r.Context()) if tenantID > 0 { logData.TenantId = tenantID } @@ -169,9 +175,9 @@ func parseResourceFromPath(path string) (resourceType string, resourceID int64) path = strings.TrimPrefix(path, "/api/") parts := strings.Split(strings.Trim(path, "/"), "/") - // Skip prefix like "admin" or "tenant" + // Skip prefix like "admin", "tenant" or "open" idx := 0 - if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant") { + if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant" || parts[0] == "open") { idx = 1 } diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 357494b..6ad0c07 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -10,42 +10,11 @@ import ( "github.com/qianfree/team-api/internal/response" ) -// RequirePermission returns middleware that checks if the admin user has the required permission. -func RequirePermission(permission string) func(r *ghttp.Request) { - return func(r *ghttp.Request) { - role := GetUserRole(r.Context()) - userID := GetUserID(r.Context()) - - if role == "super_admin" { - r.Middleware.Next() - return - } - - if !admin.HasPermission(r.Context(), userID, role, permission) { - response.ErrorMsg(r, consts.CodeForbidden, "缺少权限:"+permission) - return - } - - r.Middleware.Next() - } -} - -// RequireTenantRole returns middleware that checks if the tenant user has the required role. -func RequireTenantRole(roles ...string) func(r *ghttp.Request) { - roleSet := make(map[string]bool) - for _, r := range roles { - roleSet[r] = true - } - - return func(r *ghttp.Request) { - role := GetUserRole(r.Context()) - if !roleSet[role] { - response.ErrorMsg(r, consts.CodeForbidden, consts.MsgForbidden) - return - } - r.Middleware.Next() - } -} +// 说明:曾有 RequirePermission / RequireTenantRole 两个「按路由挂载」的中间件, +// 均无任何调用方,已删除。管理后台的授权统一由 AdminPermissionGuard + adminPermissionRules +// 集中判定(默认拒绝、规则完整性由 rbac_coverage_test.go 保证);租户控制台的角色校验 +// 在 logic 层逐方法进行。留着一个从未挂载的 RequireTenantRole 会让人误以为 +// /api/tenant 有路由级角色闸门,实际并没有。 // adminPermissionRules defines the mapping from route patterns to permission points. // Unmapped admin routes are denied by default in AdminPermissionGuard. @@ -79,6 +48,7 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/channels", perm: "channel:create"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/test", perm: "channel:test"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/clone", perm: "channel:create"}, + {method: "POST", prefix: "/api/admin/channels/", suffix: "/reset-health", perm: "channel:edit"}, {method: "POST", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:edit"}, {method: "DELETE", prefix: "/api/admin/channels/", suffix: "/keys/", perm: "channel:edit"}, {method: "GET", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:view"}, @@ -131,6 +101,8 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/usage-logs", perm: "billing:view"}, {method: "GET", path: "/api/admin/usage-logs/summary", perm: "billing:view"}, {method: "GET", path: "/api/admin/usage-logs/export", perm: "billing:export"}, + // 用量日志详情(/usage-logs/{id}):前缀兜底匹配,summary/export/cleanup 等精确规则优先 + {method: "GET", prefix: "/api/admin/usage-logs/", perm: "billing:view"}, {method: "GET", path: "/api/admin/transactions", perm: "billing:view"}, {method: "GET", path: "/api/admin/wallets", perm: "billing:view"}, {method: "GET", prefix: "/api/admin/wallets/", perm: "billing:view"}, @@ -138,7 +110,7 @@ var adminPermissionRules = []permissionRule{ {method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", perm: "billing:refund"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release-all", perm: "billing:refund"}, - {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"}, + {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:refund"}, // ── plan 套餐管理 ── {method: "GET", path: "/api/admin/plans", perm: "plan:view"}, @@ -153,7 +125,9 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/orders/export", perm: "order:view"}, {method: "GET", prefix: "/api/admin/orders/", perm: "order:view"}, {method: "POST", prefix: "/api/admin/orders/", suffix: "/refund", perm: "order:refund"}, - {method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:view"}, + // 手动完成订单会走 markOrderPaidByAdmin + payment.FulfillOrder:无支付凭证直接履约 + // (充值入钱包 / 发放套餐),是资金动作而非订单查看,按退款同级管控。 + {method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:refund"}, // ── operation 内容运营 ── {method: "GET", path: "/api/admin/changelogs", perm: "operation:view"}, @@ -180,6 +154,8 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/assign", perm: "support:edit"}, {method: "POST", prefix: "/api/admin/tickets/", suffix: "/reply", perm: "support:reply"}, {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/status", perm: "support:edit"}, + // 顶栏铃铛待办计数(工单 + 反馈聚合,权限收敛在 support:view) + {method: "GET", path: "/api/admin/support/pending-summary", perm: "support:view"}, // ── help center 帮助中心 ── {method: "GET", path: "/api/admin/help-categories", perm: "support:view"}, @@ -194,13 +170,17 @@ var adminPermissionRules = []permissionRule{ // ── audit 审计日志 ── {method: "GET", path: "/api/admin/audit/config", perm: "audit:view"}, - {method: "PUT", path: "/api/admin/audit/config", perm: "audit:view"}, + // 写 sys_options.audit_level(全局审计级别,可设为 none 关闭全站审计采集): + // 属于系统配置而非审计查看,否则只读档的技术支持可以自行关掉审计留痕。 + {method: "PUT", path: "/api/admin/audit/config", perm: "system:edit"}, {method: "GET", path: "/api/admin/audit/operation-logs", perm: "audit:view"}, {method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"}, {method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"}, {method: "GET", prefix: "/api/admin/audit/request-logs/", perm: "audit:view"}, + {method: "GET", prefix: "/api/admin/audit/forwarding-trace/", perm: "audit:view"}, {method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"}, {method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"}, + {method: "DELETE", path: "/api/admin/audit/content-filter-logs/clear", perm: "audit:clear"}, // ── file 文件管理 ── {method: "GET", path: "/api/admin/files", perm: "file:view"}, @@ -226,6 +206,7 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/error-logs/", perm: "monitor:edit"}, {method: "PUT", path: "/api/admin/error-logs/batch-resolve", perm: "monitor:edit"}, {method: "DELETE", path: "/api/admin/error-logs/clear", perm: "monitor:edit"}, + {method: "DELETE", path: "/api/admin/monitor/channel-errors/clear", perm: "monitor:edit"}, {method: "GET", path: "/api/admin/error-logs/stats", perm: "monitor:view"}, {method: "GET", path: "/api/admin/cron-jobs", perm: "system:view"}, {method: "POST", prefix: "/api/admin/cron-jobs/", suffix: "/trigger", perm: "system:edit"}, @@ -236,20 +217,20 @@ var adminPermissionRules = []permissionRule{ {method: "PUT", prefix: "/api/admin/settings/", perm: "system:edit"}, // 配置连通性测试(对象存储 / 邮件):会真实调用外部服务并可能发信,按写权限管控 {method: "POST", prefix: "/api/admin/settings/", perm: "system:edit"}, - // Payment settings & channels (system scope) - {method: "GET", path: "/api/admin/payment-channels", perm: "system:view"}, - {method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"}, - {method: "GET", path: "/api/admin/payment-settings", perm: "system:view"}, - {method: "PUT", path: "/api/admin/payment-settings", perm: "system:edit"}, - {method: "GET", prefix: "/api/admin/update/", perm: "system:update"}, - {method: "POST", prefix: "/api/admin/update/", perm: "system:update"}, + // Payment settings & channels:渠道配置含商户密钥明文,使用独立的 payment:* + // 权限点,与 system:*(系统设置)解耦,必须显式授予才能访问 + {method: "GET", path: "/api/admin/payment-channels", perm: "payment:view"}, + {method: "PUT", prefix: "/api/admin/payment-channels/", perm: "payment:edit"}, + {method: "GET", path: "/api/admin/payment-settings", perm: "payment:view"}, + {method: "PUT", path: "/api/admin/payment-settings", perm: "payment:edit"}, {method: "GET", path: "/api/admin/data-governance/settings", perm: "system:view"}, {method: "PUT", path: "/api/admin/data-governance/settings", perm: "system:edit"}, {method: "POST", prefix: "/api/admin/data-governance/", perm: "system:edit"}, {method: "GET", prefix: "/api/admin/plugins", perm: "system:plugin"}, {method: "POST", prefix: "/api/admin/plugins/", perm: "system:plugin"}, {method: "PUT", prefix: "/api/admin/plugins/", perm: "system:plugin"}, - {method: "GET", prefix: "/api/admin/email/", perm: "system:view"}, + // 邮件发送记录是运营查看通知触达效果的手段,与通知模板同属内容运营,不归系统设置 + {method: "GET", prefix: "/api/admin/email/", perm: "operation:view"}, // ── tenant level config 租户等级配置 ── {method: "GET", path: "/api/admin/tenant-level-configs", perm: "tenant:view"}, @@ -267,10 +248,12 @@ var adminPermissionRules = []permissionRule{ // ── member 成员管理(管理后台维度) ── {method: "GET", path: "/api/admin/members", perm: "member:view"}, {method: "POST", path: "/api/admin/members", perm: "member:import"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:view"}, + // 禁用/启用/改密/解锁都是对租户成员账号的处置(改密等于接管该成员账号, + // 进而可用其 API Key 消耗租户余额),必须走写权限点,不能沿用只读的 member:view。 + {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:manage"}, // ── redemption 兑换码管理 ── {method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"}, @@ -279,17 +262,33 @@ var adminPermissionRules = []permissionRule{ {method: "POST", path: "/api/admin/redemptions", perm: "redemption:create"}, {method: "PUT", prefix: "/api/admin/redemptions/", perm: "redemption:edit"}, + // ── role 角色管理(复用 user 组权限点) ── + // 账号管理与角色管理是同一件事的两面(谁能进后台、能干什么),拆成两组权限没有实际场景: + // 「能建角色但不能分配给人」毫无意义,「能分配角色但不能建角色」用禁用角色即可表达。 + {method: "GET", path: "/api/admin/roles", perm: "user:view"}, + {method: "POST", path: "/api/admin/roles", perm: "user:create"}, + {method: "POST", prefix: "/api/admin/roles/", suffix: "/reset", perm: "user:edit"}, + {method: "GET", prefix: "/api/admin/roles/", perm: "user:view"}, + {method: "PUT", prefix: "/api/admin/roles/", suffix: "/status", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/roles/", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/roles/", perm: "user:delete"}, + {method: "GET", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:view"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:edit"}, + // ── permission 权限管理(仅 user 组) ── {method: "GET", path: "/api/admin/permissions", perm: "user:view"}, {method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"}, {method: "PUT", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:edit"}, - {method: "PUT", prefix: "/api/admin/users/", suffix: "/data-scopes", perm: "user:edit"}, // ── admin session and security ── {method: "POST", path: "/api/admin/auth/logout", perm: "self:access"}, - {method: "GET", path: "/api/admin/auth/sessions", perm: "self:access"}, - {method: "DELETE", prefix: "/api/admin/auth/sessions/user/", perm: "user:edit"}, - {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "self:access"}, + // 当前用户信息与有效权限:登录即可访问自己的资料,不需要任何业务权限点 + {method: "GET", path: "/api/admin/auth/me", perm: "self:access"}, + // 会话管理列出并处置的是【全部管理员】的登录态,属于账号管理(user 域)的操作能力: + // 按用户管理模块的操作档位(user:edit)授权,只读档位不可见。 + // 超管会话的额外保护在 logic 层(assertCanOperateSessions):仅账号本人可操作。 + {method: "GET", path: "/api/admin/auth/sessions", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "user:edit"}, {method: "PUT", path: "/api/admin/auth/change-password", perm: "self:access"}, {method: "POST", prefix: "/api/admin/security/2fa/", perm: "self:access"}, {method: "GET", path: "/api/admin/security/login-history", perm: "audit:view"}, @@ -319,6 +318,53 @@ var adminPermissionRules = []permissionRule{ {method: "POST", prefix: "/api/admin/usage-logs/cleanup/tasks/", suffix: "/cancel", perm: "system:edit"}, } +// superAdminOnlyRules 列出仅超级管理员可访问的路由。 +// +// 角色管理不走「权限点授权」而是硬性限定超管,理由是它是权限体系的元操作: +// 谁能改角色,谁就能决定所有人的权限。一旦把它下放出去(哪怕只给 user:edit), +// 被下放者就能给自己挂上更高权限的角色——权限体系当场失效。 +// +// 这条限制让提权路径根本不存在,而不是靠「只能授予自己已有的权限」这类兜底去堵。 +// 普通账号管理(创建/禁用/改密/删除)不受影响,仍按 user:* 授权,可以正常下放。 +var superAdminOnlyRules = []permissionRule{ + // 角色的增删改查与启停 + {method: "GET", path: "/api/admin/roles"}, + {method: "POST", path: "/api/admin/roles"}, + {method: "GET", prefix: "/api/admin/roles/"}, + {method: "POST", prefix: "/api/admin/roles/"}, + {method: "PUT", prefix: "/api/admin/roles/"}, + {method: "DELETE", prefix: "/api/admin/roles/"}, + // 给账号分配角色 —— 这是最直接的提权入口 + {method: "GET", prefix: "/api/admin/users/", suffix: "/roles"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/roles"}, + + // 在线自更新(版本状态 / 检查 / 执行 / 回滚):替换平台二进制并重启服务, + // 等同于在服务器上执行代码,是比角色管理更根本的平台级元操作,不下放。 + // 版本检查(GET)看似只读,但前端每次刷新都会请求,若走权限点授权, + // 未授权角色会持续收到 403 —— 硬性限定超管后非超管前端不再发起请求。 + {method: "GET", prefix: "/api/admin/update/"}, + {method: "POST", prefix: "/api/admin/update/"}, +} + +// isSuperAdminOnly 判断路由是否仅限超级管理员。 +// 任一规则命中即为真,不需要优先级判定。 +func isSuperAdminOnly(method, path string) bool { + for _, rule := range superAdminOnlyRules { + if rule.method != method { + continue + } + if rule.path != "" && path == rule.path { + return true + } + if rule.prefix != "" && strings.HasPrefix(path, rule.prefix) { + if rule.suffix == "" || suffixMatches(path, rule.suffix) { + return true + } + } + } + return false +} + type permissionRule struct { method string // HTTP method path string // exact path match (mutually exclusive with prefix) @@ -328,10 +374,26 @@ type permissionRule struct { } // AdminPermissionGuard enforces RBAC permission checks for admin routes. -// 当前策略:未匹配到权限规则的接口默认放行(仅对已认证 admin)。 -// 已配置规则的接口仍按权限点鉴权;漏配的接口不至于直接 403,降低上线回归风险。 -// 待 adminPermissionRules 全量补齐后,可将下方 perm=="" 分支改回返回 403 以收紧权限。 +// +// 策略:默认拒绝 —— 未匹配到权限规则的接口一律 403。 +// +// 早期为降低上线回归风险曾采用「未匹配即放行」,那在只有 super_admin / admin 两种可信 +// 身份时无害;引入运营、技术支持等低权限角色后,放行等同于越权:任何漏配规则的接口对 +// 所有角色都是敞开的。改为默认拒绝后,遗漏会立刻表现为 403 而不是静默的权限缺口。 +// +// 兜底保证:super_admin 在规则匹配之前短路放行,因此即便规则有遗漏也不会把系统锁死, +// 超管始终可以进入并修复。规则的完整性由 rbac_coverage_test.go 在 CI 阶段保证。 func AdminPermissionGuard(r *ghttp.Request) { + // 路径必须是规范形式:GoFrame 不会归一化重复斜杠,/api/admin//roles 会带着原样路径 + // 进到 handler,而规则表按精确路径/前缀匹配,同一个 handler 就有了两种匹配结果。 + // 目前默认拒绝把这类变体都挡在外面,但那是「恰好安全」——只要将来某条前缀规则的 + // 权限点比精确规则弱,加一个斜杠就能降档。与其依赖这个巧合,不如直接拒收非规范路径: + // 正常客户端不会产生它,攻击者才需要它。 + if !isCanonicalPath(r.URL.Path) { + response.ErrorMsg(r, consts.CodeForbidden, "非法的请求路径") + return + } + if isAdminPublicPath(r.URL.Path) { r.Middleware.Next() return @@ -343,10 +405,16 @@ func AdminPermissionGuard(r *ghttp.Request) { return } + // 角色管理与在线自更新是平台级元操作,硬性限定超管,不参与权限点授权 + if isSuperAdminOnly(r.Method, r.URL.Path) { + response.ErrorMsg(r, consts.CodeForbidden, "该操作仅超级管理员可用") + return + } + perm := matchPermission(r.Method, r.URL.Path) if perm == "" { - // 默认放行:未配置权限规则的接口,对已认证 admin 放行 - r.Middleware.Next() + // 默认拒绝:未配置权限规则的接口不放行(super_admin 已在上方短路) + response.ErrorMsg(r, consts.CodeForbidden, "接口未配置权限规则,请联系管理员") return } @@ -364,6 +432,22 @@ func AdminPermissionGuard(r *ghttp.Request) { r.Middleware.Next() } +// isCanonicalPath 判断路径是否为规范形式:不含空路径段("//")与 "." / ".." 段。 +// +// 只做判定不做重写:重写会把「当前被默认拒绝挡住的变体」变成放行,等于放宽; +// 直接拒收既不放宽也不留下将来被利用的缝隙。 +func isCanonicalPath(path string) bool { + if path == "" { + return false + } + for _, seg := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if seg == "" || seg == "." || seg == ".." { + return false + } + } + return true +} + // matchPermission finds the permission required for the given method and path. // Matching priority: exact path > prefix+suffix > prefix-only (catch-all). // This three-phase approach eliminates rule ordering dependencies. diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go new file mode 100644 index 0000000..d9e23a8 --- /dev/null +++ b/internal/middleware/rbac_coverage_test.go @@ -0,0 +1,310 @@ +package middleware + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/qianfree/team-api/internal/logic/admin" +) + +// adminAPIDir 是管理后台 API 定义所在目录(相对本测试文件)。 +// 路由由 cmd.go 中 g.Bind(adminController.NewV1()) 从这些 g.Meta 注解注册, +// 前缀统一为 /api/admin。 +const adminAPIDir = "../../api/admin/v1" + +// adminRoutePrefix 与 internal/cmd/cmd.go 的 group.Group("/admin", ...) 保持一致。 +const adminRoutePrefix = "/api/admin" + +// manualAdminRoutes 列出不经 g.Bind(adminController) 注册、但同样挂在 +// AdminPermissionGuard 之下的路由(见 internal/cmd/cmd.go 的独立路由组)。 +// 这些路由没有 g.Meta 注解,AST 扫不到,需手工登记。 +var manualAdminRoutes = []adminRoute{ + {Method: "GET", Path: "/api/admin/files/{id}/serve"}, +} + +type adminRoute struct { + Method string + Path string + Source string // 来源文件:行,用于报错定位 +} + +// collectAdminRoutes 解析 api/admin/v1 下全部 g.Meta 注解,得到已注册的 admin 路由清单。 +// +// 这里刻意用 AST 解析源码而不是 ghttp.Server.GetRoutes():后者需要导入 +// internal/controller/admin,而它反向依赖 internal/middleware,会造成 import cycle。 +// 解析源码同样精确(路由本就由这些注解生成),且不需要构造 server 或任何外部依赖。 +func collectAdminRoutes(t *testing.T) []adminRoute { + t.Helper() + + files, err := filepath.Glob(filepath.Join(adminAPIDir, "*.go")) + if err != nil { + t.Fatalf("扫描 %s 失败: %v", adminAPIDir, err) + } + + fset := token.NewFileSet() + seen := make(map[string]bool) + var out []adminRoute + + add := func(r adminRoute) { + key := r.Method + " " + r.Path + if seen[key] { + return + } + seen[key] = true + out = append(out, r) + } + + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("解析 %s 失败: %v", file, err) + } + + ast.Inspect(f, func(n ast.Node) bool { + st, ok := n.(*ast.StructType) + if !ok || st.Fields == nil { + return true + } + for _, field := range st.Fields.List { + // g.Meta 是嵌入字段(无字段名),带 path/method 标签 + if len(field.Names) != 0 || field.Tag == nil { + continue + } + raw, err := strconv.Unquote(field.Tag.Value) + if err != nil { + continue + } + tag := reflect.StructTag(raw) + path := strings.TrimSpace(tag.Get("path")) + if path == "" { + continue + } + pos := fset.Position(field.Pos()) + for _, m := range strings.Split(tag.Get("method"), ",") { + m = strings.ToUpper(strings.TrimSpace(m)) + if m == "" { + m = "GET" // g.Meta 省略 method 时 GoFrame 默认注册 GET + } + add(adminRoute{ + Method: m, + Path: adminRoutePrefix + path, + Source: filepath.Base(pos.Filename) + ":" + strconv.Itoa(pos.Line), + }) + } + } + return true + }) + } + + for _, r := range manualAdminRoutes { + r.Source = "internal/cmd/cmd.go(手工注册)" + add(r) + } + + if len(out) == 0 { + t.Fatalf("未从 %s 解析到任何路由,测试装置失效", adminAPIDir) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Path != out[j].Path { + return out[i].Path < out[j].Path + } + return out[i].Method < out[j].Method + }) + return out +} + +// concreteFromPattern 把路由模式里的占位段替换成具体值, +// 使其可以喂给 matchPermission(它按具体路径做前缀/后缀匹配,不认识占位符)。 +// +// /api/admin/users/{id}/permissions -> /api/admin/users/1/permissions +// /api/admin/channels/:id -> /api/admin/channels/1 +func concreteFromPattern(pattern string) string { + segs := strings.Split(pattern, "/") + for i, seg := range segs { + if seg == "" { + continue + } + if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") { + segs[i] = "1" + } + } + return strings.Join(segs, "/") +} + +// allPermissionPoints 收集 predefinedPermissionGroups 里声明的全部权限点, +// 外加中间件自身消费的伪权限点。 +func allPermissionPoints() map[string]bool { + set := map[string]bool{ + // self:access 不属于任何模块分组:它表示「登录即可访问自己的资源」, + // 在 AdminPermissionGuard 里被直接放行,不参与角色授权。 + "self:access": true, + } + for _, g := range admin.PermissionGroups() { + for _, p := range g.Permissions { + set[p] = true + } + } + return set +} + +// TestEveryAdminRouteHasPermissionRule 保证每个受保护的 admin 接口都能匹配到权限规则。 +// +// AdminPermissionGuard 对未匹配到规则的路由返回 403(默认拒绝)。若新增接口时忘了在 +// adminPermissionRules 登记,低权限角色会拿到一个无法解释的 403 —— 本测试把这类遗漏 +// 在 CI 阶段拦下,而不是等到线上有人点开某个页面才发现。 +func TestEveryAdminRouteHasPermissionRule(t *testing.T) { + var missing []string + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) { + continue + } + // 超管专属路由(角色管理、在线自更新)由 superAdminOnlyRules 硬性拦截, + // 先于权限规则判定,不走权限点授权,因此不要求出现在 adminPermissionRules 里。 + if isSuperAdminOnly(r.Method, path) { + continue + } + if perm := matchPermission(r.Method, path); perm == "" { + missing = append(missing, r.Method+" "+r.Path+" ("+r.Source+")") + } + } + + if len(missing) > 0 { + t.Fatalf("以下 %d 个 admin 接口未配置权限规则(请在 adminPermissionRules 中补齐):\n %s", + len(missing), strings.Join(missing, "\n ")) + } +} + +// TestPermissionRulesReferenceKnownPoints 保证规则里引用的权限点都是真实存在的。 +// +// 规则里写错一个字(如 channel:veiw)不会有任何编译期报错,但会造成该接口对所有 +// 非超管角色永久 403 —— 因为没有任何角色能被授予一个不存在的权限点。 +func TestPermissionRulesReferenceKnownPoints(t *testing.T) { + known := allPermissionPoints() + + for _, rule := range adminPermissionRules { + if !known[rule.perm] { + target := rule.path + if target == "" { + target = rule.prefix + "*" + rule.suffix + } + t.Errorf("规则 %s %s 引用了未定义的权限点 %q", rule.method, target, rule.perm) + } + } +} + +// TestMatchedPermissionsAreGrantable 从路由侧再验一次: +// 每个受保护接口最终要求的权限点,都必须是可以授予某个角色的。 +func TestMatchedPermissionsAreGrantable(t *testing.T) { + known := allPermissionPoints() + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) { + continue + } + perm := matchPermission(r.Method, path) + if perm == "" { + continue // 由 TestEveryAdminRouteHasPermissionRule 负责报错 + } + if !known[perm] { + t.Errorf("接口 %s %s 要求权限点 %q,但该权限点未在 predefinedPermissionGroups 中定义", + r.Method, r.Path, perm) + } + } +} + +// writeOnViewPermExceptions 列出「写方法但语义上是读」的接口白名单。 +// +// 加白之前先问一句:这个接口有没有改变任何持久状态?只有答案是「没有,用 POST 纯粹是 +// 因为查询参数太复杂放不进 query string」时才可以加,并写明理由。 +var writeOnViewPermExceptions = map[string]string{ + // 导出模型配置为 JSON:只读取并序列化现有配置,不写库。用 POST 是因为筛选条件是 + // 一个可能很大的模型 ID 数组。 + "POST /api/admin/models/export-json": "纯导出,不改变任何状态", +} + +// TestWriteRoutesDoNotUseViewPermissions 保证写接口不会挂在只读权限点上。 +// +// 这是本次权限审计里出现最多的一类缺陷:接口按 :view 配置,于是「技术支持」这类被明确 +// 定位成只读的角色,实际拥有了改密、改配置、履约订单的能力。权限点的档位语义 +// (read/operate/full)只有在规则表遵守它时才成立,靠人工 review 守不住这条线。 +func TestWriteRoutesDoNotUseViewPermissions(t *testing.T) { + for _, r := range collectAdminRoutes(t) { + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { + continue + } + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) || isSuperAdminOnly(r.Method, path) { + continue + } + if _, ok := writeOnViewPermExceptions[r.Method+" "+r.Path]; ok { + continue + } + perm := matchPermission(r.Method, path) + if strings.HasSuffix(perm, ":view") { + t.Errorf("写接口 %s %s 挂在只读权限点 %q 上:只读角色会因此获得写能力。"+ + "请改挂对应的操作/完全档权限点;若该接口确实不改变任何状态,"+ + "把它加进 writeOnViewPermExceptions 并说明理由", r.Method, r.Path, perm) + } + } +} + +// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。 +// +// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条 +// 限制,一个被下放了 user:edit 的账号就能给自己挂上更高权限的角色,整套角色体系当场失效。 +// 新增角色相关路由时,本测试会强制你同步登记 superAdminOnlyRules。 +func TestRoleManagementIsSuperAdminOnly(t *testing.T) { + var leaked []string + + for _, r := range collectAdminRoutes(t) { + path := concreteFromPattern(r.Path) + isRoleRoute := strings.HasPrefix(r.Path, "/api/admin/roles") || + strings.HasSuffix(r.Path, "/roles") + if !isRoleRoute { + continue + } + if !isSuperAdminOnly(r.Method, path) { + leaked = append(leaked, r.Method+" "+r.Path+" ("+r.Source+")") + } + } + + if len(leaked) > 0 { + t.Fatalf("以下角色管理接口未限定为超级管理员专属(请补入 superAdminOnlyRules):\n %s", + strings.Join(leaked, "\n ")) + } +} + +// TestSuperAdminOnlyRulesDoNotOverreach 反向确认:超管专属清单不应误伤普通账号管理。 +// +// 账号管理(创建/禁用/改密/删除)按 user:* 授权、可以正常下放,只有「分配角色」才是 +// 权限体系的元操作。两者路径相邻(都在 /api/admin/users/ 下),容易被前缀规则误伤。 +func TestSuperAdminOnlyRulesDoNotOverreach(t *testing.T) { + shouldStayDelegatable := []struct{ method, path string }{ + {"GET", "/api/admin/users"}, + {"POST", "/api/admin/users"}, + {"PUT", "/api/admin/users/1"}, + {"PUT", "/api/admin/users/1/status"}, + {"PUT", "/api/admin/users/1/reset-password"}, + {"PUT", "/api/admin/users/1/unlock"}, + {"DELETE", "/api/admin/users/1"}, + {"GET", "/api/admin/users/1/permissions"}, + } + for _, c := range shouldStayDelegatable { + if isSuperAdminOnly(c.method, c.path) { + t.Errorf("%s %s 属于普通账号管理,不应被限定为超管专属", c.method, c.path) + } + } +} diff --git a/internal/middleware/request_id_security_test.go b/internal/middleware/request_id_security_test.go index 9ff0685..6d0c24c 100644 --- a/internal/middleware/request_id_security_test.go +++ b/internal/middleware/request_id_security_test.go @@ -1,50 +1,81 @@ package middleware import ( + "fmt" + "strings" "testing" + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/gclient" "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/test/gtest" ) -// TestRequestId_ServerIDGeneration 测试服务端 ID 强制生成 +// startRequestIdServer 起一个只挂 RequestId 中间件的测试服务, +// 回显服务端 ID 与客户端追踪 ID,供本文件的用例复用。 +// +// 注意:RequestId 依赖 ghttp 的响应对象与中间件链,无法脱离 server 直接构造 +// ghttp.Request 来调用(本文件早期版本曾如此尝试,用的 API 并不存在,从未编译通过)。 +func startRequestIdServer(t *gtest.T, name string) (*ghttp.Server, *gclient.Client) { + s := g.Server(name) + s.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(RequestId) + group.ALL("/test", func(r *ghttp.Request) { + r.Response.WriteJson(g.Map{ + "server_id": r.GetCtxVar("RequestId").String(), + "client_id": r.GetCtxVar("ClientTraceId").String(), + }) + }) + }) + s.SetAddr("127.0.0.1:0") + s.SetDumpRouterMap(false) + s.Start() + + client := g.Client() + client.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + return s, client +} + +// TestRequestId_ServerIDGeneration 服务端 ID 必须每次强制生成且互不相同, +// 无论客户端是否传入 X-Request-Id。 func TestRequestId_ServerIDGeneration(t *testing.T) { gtest.C(t, func(t *gtest.T) { - // 模拟请求上下文 - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Response = ghttp.DefaultServer().GetRouterTree() + s, client := startRequestIdServer(t, "request-id-server-gen") + defer s.Shutdown() - // 测试场景1:无客户端 ID - r.Request.Header.Set("X-Request-Id", "") - RequestId(r) + ctx := gctx.New() - serverID := r.GetCtxVar("RequestId").String() - clientID := r.GetCtxVar("ClientTraceId").String() + // 场景 1:无客户端 ID + body1 := client.GetContent(ctx, "/test") + j1 := gjson.New(body1) + serverID1 := j1.Get("server_id").String() + t.AssertNE(serverID1, "") + t.Assert(j1.Get("client_id").String(), "") - t.Assert(serverID != "", true, "服务端 ID 必须生成") - t.Assert(clientID, "", "无客户端 ID 时 ClientTraceId 应为空") + // 场景 2:有效客户端 ID —— 服务端 ID 仍独立生成 + body2 := client.Header(g.MapStrStr{"X-Request-Id": "valid-client-123"}). + GetContent(ctx, "/test") + j2 := gjson.New(body2) + serverID2 := j2.Get("server_id").String() + t.AssertNE(serverID2, "") + t.Assert(j2.Get("client_id").String(), "valid-client-123") - // 测试场景2:有效客户端 ID - r2 := &ghttp.Request{} - r2.Request = gtest.NewRequest("GET", "/test") - r2.Request.Header.Set("X-Request-Id", "valid-client-123") - r2.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r2) - - serverID2 := r2.GetCtxVar("RequestId").String() - clientID2 := r2.GetCtxVar("ClientTraceId").String() - - t.Assert(serverID2 != "", true, "服务端 ID 必须生成") - t.Assert(clientID2, "valid-client-123", "有效客户端 ID 应保存") - t.Assert(serverID != serverID2, true, "两次请求的服务端 ID 必须不同") + // 两次请求的服务端 ID 必须不同(幂等键依赖其唯一性) + t.AssertNE(serverID1, serverID2) }) } -// TestRequestId_ClientIDValidation 测试客户端 ID 格式校验 +// TestRequestId_ClientIDValidation 客户端追踪 ID 的格式校验: +// 仅接受字母/数字/连字符且长度 1-64,其余一律丢弃,防止日志注入与不可控的下游传播。 func TestRequestId_ClientIDValidation(t *testing.T) { gtest.C(t, func(t *gtest.T) { + s, client := startRequestIdServer(t, "request-id-client-validation") + defer s.Shutdown() + + ctx := gctx.New() + tests := []struct { name string clientID string @@ -53,106 +84,60 @@ func TestRequestId_ClientIDValidation(t *testing.T) { {"有效ID-字母数字连字符", "client-trace-123", true}, {"有效ID-纯数字", "12345678", true}, {"有效ID-纯字母", "abcdefgh", true}, + {"有效ID-64位边界", strings.Repeat("a", 64), true}, {"非法ID-含空格", "invalid id", false}, - {"非法ID-超长", string(make([]byte, 65)), false}, + {"非法ID-超长65位", strings.Repeat("a", 65), false}, {"非法ID-特殊字符", "id@#$%", false}, {"非法ID-中文", "客户端ID", false}, } for _, tt := range tests { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", tt.clientID) - r.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r) - - clientID := r.GetCtxVar("ClientTraceId").String() + body := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}). + GetContent(ctx, "/test") + clientID := gjson.New(body).Get("client_id").String() if tt.shouldAccept { - t.Assert(clientID, tt.clientID, tt.name+" 应被接受") + t.AssertEQ(clientID, tt.clientID) } else { - t.Assert(clientID, "", tt.name+" 应被拒绝") + t.AssertEQ(clientID, "") } } }) } -// TestRequestId_SecurityIsolation 测试安全隔离 -func TestRequestId_SecurityIsolation(t *testing.T) { - gtest.C(t, func(t *gtest.T) { - serverIDs := make(map[string]bool) - maliciousClientID := "malicious-repeated-id" - - // 模拟恶意客户端:10 次请求使用相同的客户端 ID - for i := 0; i < 10; i++ { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", maliciousClientID) - r.Response = ghttp.DefaultServer().GetRouterTree() - - RequestId(r) - - serverID := r.GetCtxVar("RequestId").String() - serverIDs[serverID] = true - } - - // 验证:每次请求的服务端 ID 必须不同 - t.Assert(len(serverIDs), 10, "10 次请求应生成 10 个不同的服务端 ID") - }) -} - -// TestRequestId_ResponseHeaders 测试响应头设置 +// TestRequestId_ResponseHeaders 响应头回显规则: +// - X-Teamapi-Request-Id 恒为服务端 ID +// - X-Request-Id 在客户端 ID 有效时回显客户端 ID,否则回填服务端 ID func TestRequestId_ResponseHeaders(t *testing.T) { gtest.C(t, func(t *gtest.T) { + s, client := startRequestIdServer(t, "request-id-response-headers") + defer s.Shutdown() + + ctx := gctx.New() + tests := []struct { - name string - clientID string - expectXTeamapiRequestId bool - expectXRequestIdEcho string + name string + clientID string + echoesSrv bool // X-Request-Id 是否应回显服务端 ID }{ - { - name: "无客户端 ID", - clientID: "", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "server", // 应回显服务端 ID - }, - { - name: "有效客户端 ID", - clientID: "valid-123", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "valid-123", // 应回显客户端 ID - }, - { - name: "非法客户端 ID", - clientID: "invalid id with spaces", - expectXTeamapiRequestId: true, - expectXRequestIdEcho: "server", // 应回显服务端 ID - }, + {"无客户端 ID", "", true}, + {"有效客户端 ID", "valid-123", false}, + {"非法客户端 ID", "invalid id with spaces", true}, } for _, tt := range tests { - r := &ghttp.Request{} - r.Request = gtest.NewRequest("GET", "/test") - r.Request.Header.Set("X-Request-Id", tt.clientID) - r.Response = ghttp.DefaultServer().GetRouterTree() + resp, err := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}). + Get(ctx, "/test") + t.AssertNil(err) - RequestId(r) + serverID := gjson.New(resp.ReadAllString()).Get("server_id").String() + resp.Close() - serverID := r.GetCtxVar("RequestId").String() - xTeamapiRequestId := r.Response.Header().Get("X-Teamapi-Request-Id") - xRequestId := r.Response.Header().Get("X-Request-Id") - - // 验证 X-Teamapi-Request-Id - if tt.expectXTeamapiRequestId { - t.Assert(xTeamapiRequestId, serverID, tt.name+": X-Teamapi-Request-Id 应等于服务端 ID") - } - - // 验证 X-Request-Id 回显逻辑 - if tt.expectXRequestIdEcho == "server" { - t.Assert(xRequestId, serverID, tt.name+": X-Request-Id 应回显服务端 ID") + t.AssertEQ(resp.Header.Get("X-Teamapi-Request-Id"), serverID) + if tt.echoesSrv { + t.AssertEQ(resp.Header.Get("X-Request-Id"), serverID) } else { - t.Assert(xRequestId, tt.expectXRequestIdEcho, tt.name+": X-Request-Id 应回显客户端 ID") + t.AssertEQ(resp.Header.Get("X-Request-Id"), tt.clientID) } } }) diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go index 1003d89..0bdc95d 100644 --- a/internal/middleware/request_id_test.go +++ b/internal/middleware/request_id_test.go @@ -16,7 +16,7 @@ import ( // TestRequestId_DualIDMechanism 测试双 ID 机制 func TestRequestId_DualIDMechanism(t *testing.T) { gtest.C(t, func(t *gtest.T) { - s := g.Server(gtest.DataPath("request-id-test")) + s := g.Server("mw-request-id-dual") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -28,6 +28,7 @@ func TestRequestId_DualIDMechanism(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() @@ -98,7 +99,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) { serverIDs := make(map[string]bool) var mu sync.Mutex - s := g.Server(gtest.DataPath("request-id-security")) + s := g.Server("mw-request-id-isolation") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -111,6 +112,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() @@ -138,7 +140,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) { const concurrency = 100 serverIDs := make(chan string, concurrency) - s := g.Server(gtest.DataPath("request-id-idempotency")) + s := g.Server("mw-request-id-idempotency") s.Group("/", func(group *ghttp.RouterGroup) { group.Middleware(RequestId) group.ALL("/test", func(r *ghttp.Request) { @@ -148,6 +150,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) { }) }) }) + s.SetAddr("127.0.0.1:0") s.SetDumpRouterMap(false) s.Start() defer s.Shutdown() diff --git a/internal/middleware/tenant_auth.go b/internal/middleware/tenant_auth.go index 35d7792..83990c2 100644 --- a/internal/middleware/tenant_auth.go +++ b/internal/middleware/tenant_auth.go @@ -14,19 +14,21 @@ import ( // tenantPublicPaths lists tenant routes that skip JWT auth. // Keep in sync with api/tenant/v1/ structs tagged group:"public" middleware:"-". var tenantPublicPaths = map[string]bool{ - "/api/tenant/auth/register": true, - "/api/tenant/auth/login": true, - "/api/tenant/auth/refresh": true, - "/api/tenant/auth/2fa/verify": true, - "/api/tenant/email/send-code": true, - "/api/tenant/email/reset-password": true, - "/api/tenant/members/join": true, - "/api/tenant/members/invite-info": true, - "/api/tenant/agreements/current": true, - "/api/tenant/oauth/authorize": true, - "/api/tenant/marketplace/models": true, - "/api/tenant/help/categories": true, - "/api/tenant/help/search": true, + "/api/tenant/auth/register": true, + // 注册页在提交前展示剩余注册次数,必须匿名可访问(只读不计数) + "/api/tenant/auth/register-rate-limit": true, + "/api/tenant/auth/login": true, + "/api/tenant/auth/refresh": true, + "/api/tenant/auth/2fa/verify": true, + "/api/tenant/email/send-code": true, + "/api/tenant/email/reset-password": true, + "/api/tenant/members/join": true, + "/api/tenant/members/invite-info": true, + "/api/tenant/agreements/current": true, + "/api/tenant/oauth/authorize": true, + "/api/tenant/marketplace/models": true, + "/api/tenant/help/categories": true, + "/api/tenant/help/search": true, } // tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}). diff --git a/internal/model/do/aud_request_logs.go b/internal/model/do/aud_request_logs.go index 3cd909a..f33f653 100644 --- a/internal/model/do/aud_request_logs.go +++ b/internal/model/do/aud_request_logs.go @@ -42,4 +42,5 @@ type AudRequestLogs struct { TaskResult any // 异步任务完成时上游返回的原始响应体 TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt *gtime.Time // 异步任务达到终态的时间 + ModelName any // 请求使用的模型名(用户请求体中的原始模型,未经渠道映射) } diff --git a/internal/model/do/bil_usage_logs.go b/internal/model/do/bil_usage_logs.go index 84240bc..f91002b 100644 --- a/internal/model/do/bil_usage_logs.go +++ b/internal/model/do/bil_usage_logs.go @@ -69,4 +69,6 @@ type BilUsageLogs struct { BillingSnapshot any // 完整计费计算过程快照(JSONB) ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目) TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空 + DurationSeconds any // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL + UpstreamRequestId any // 上游请求ID:对话=上游响应头请求ID(X-Oneapi-Request-Id等),任务=上游任务ID;排障反查用 } diff --git a/internal/model/do/bil_wallets.go b/internal/model/do/bil_wallets.go index 67fdda5..e11122c 100644 --- a/internal/model/do/bil_wallets.go +++ b/internal/model/do/bil_wallets.go @@ -23,4 +23,5 @@ type BilWallets struct { UpdatedAt *gtime.Time // 更新时间 CumulativeRecharge any // 累计充值总额(USD) LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false) + TotalConsumed any // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减) } diff --git a/internal/model/do/mdl_models.go b/internal/model/do/mdl_models.go index 0a54954..5bc4e80 100644 --- a/internal/model/do/mdl_models.go +++ b/internal/model/do/mdl_models.go @@ -27,4 +27,5 @@ type MdlModels struct { DeprecatedAt *gtime.Time // 标记弃用的时间(NULL表示未弃用) SunsetDate *gtime.Time // 计划下线日期(到达后返回410 Gone,NULL表示未设置) ReplacementModel any // 推荐替代模型名(NULL表示无替代) + Vendor any // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类 } diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 608b74b..ed361fb 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -7,26 +7,19 @@ package do import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" - "github.com/shopspring/decimal" ) // MdlPricing is the golang structure of table mdl_pricing for DAO operations like Where/Data. type MdlPricing struct { - g.Meta `orm:"table:mdl_pricing, do:true"` - Id any // - ModelId any // 关联模型ID - BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量) - MinTokens any // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0) - MaxTokens any // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式) - InputPrice any // 每 1M input token 价格(token/tiered 模式) - OutputPrice any // 每 1M output token 价格(token/tiered 模式) - PerRequestPrice *decimal.Decimal // 按次计费单价(仅 per_request 模式) - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // - CacheReadPrice any // 缓存读取每 1M token 价格(直接定价) - CacheCreationPrice any // 缓存创建每 1M token 价格(直接定价) - TimeSegments any // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end", + }, + } + + 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/internal/utility/export/export.go b/internal/utility/export/export.go index 658a837..7a314dd 100644 --- a/internal/utility/export/export.go +++ b/internal/utility/export/export.go @@ -2,7 +2,9 @@ package export import ( "context" + "fmt" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" ) @@ -31,20 +33,57 @@ func (c Config) GetMaxRows() int { // GenericExport handles format normalization and xlsx/csv branching. // queryFn yields data rows — same callback as StreamCSV's queryFn. -// For xlsx: collects all rows from queryFn, then calls WriteExcel. -// For csv: passes queryFn directly to StreamCSV for streaming. +// For xlsx: collects rows from queryFn (capped), then calls WriteExcel. +// For csv: passes queryFn to StreamCSV for streaming (capped). +// +// 行数上限在这里统一强制执行(对拉取过程截停,而不是拉完再检查): +// 导出的数据源是「无总量保证」的分批循环查询,上限是防止一次导出请求 +// 拖着数据库扫全量大表的最后一道护栏。 +// - xlsx:达到上限直接返回错误(此时还未写出任何响应,能干净地报错), +// 并且不再继续拉取 —— 旧实现是全部拉进内存后才检查,护栏形同虚设; +// - csv:边查边流式写出,表头和数据已经发给客户端,超限时无法再改报错误, +// 只能停止拉取并在文件末尾追加一行可见的截断标记,避免使用者 +// 误把部分数据当作全量。 func GenericExport(ctx context.Context, config Config, queryFn func(yield func(map[string]any) bool)) error { r := g.RequestFromCtx(ctx) config.Format = detectFormat(config.Format) + maxRows := config.GetMaxRows() if config.Format == "xlsx" { - var data []map[string]any + var ( + data []map[string]any + overflow bool + ) queryFn(func(row map[string]any) bool { + if len(data) >= maxRows { + overflow = true + return false + } data = append(data, row) return true }) + if overflow { + return gerror.Newf("数据量超过 %d 行,请缩小筛选范围后重试,或改用 CSV 格式导出", maxRows) + } return WriteExcel(r, config, data) } - return StreamCSV(r, config, queryFn) + truncated := false + capped := func(yield func(map[string]any) bool) { + rowCount := 0 + queryFn(func(row map[string]any) bool { + if rowCount >= maxRows { + truncated = true + return false + } + rowCount++ + return yield(row) + }) + if truncated && len(config.Columns) > 0 { + yield(map[string]any{ + config.Columns[0].Field: fmt.Sprintf("注意:已达到导出上限 %d 行,结果被截断,请缩小时间范围或筛选条件后分批导出", maxRows), + }) + } + } + return StreamCSV(r, config, capped) } diff --git a/internal/utility/totp/totp.go b/internal/utility/totp/totp.go index 8318f60..f5a6917 100644 --- a/internal/utility/totp/totp.go +++ b/internal/utility/totp/totp.go @@ -23,6 +23,7 @@ const ( // 验证器 App 生成的 SHA-256 码在服务端永远校验失败。要安全切换到 SHA-256 必须先: // 1. 在用户表持久化每条 secret 对应的算法; // 2. ValidateCode 改用 totp.ValidateCustom 并按存储的算法校验。 +// // 这属于涉及 schema 与 2FA 安全路径的较大改动,超出 P3 细节优化范围,故暂保留 SHA-1。 func GenerateSecret(accountName string) (secret string, uri string, err error) { key, err := totp.Generate(totp.GenerateOpts{ diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 12c1a6d..031998c 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -83,9 +83,6 @@ demo: enabled: false message: "演示环境,数据不可修改" -# Channel proxy (for accessing Google/Anthropic etc. from China) -channel_proxy_url: "" - # System update source override # Default: GitHub official API (https://api.github.com/repos/qianfree/team-api). # Override to point at a self-hosted release server / local mock release (see diff --git a/migrations/000020_v0_2_17_version.sql b/migrations/000020_v0_2_17_version.sql new file mode 100644 index 0000000..2190d60 --- /dev/null +++ b/migrations/000020_v0_2_17_version.sql @@ -0,0 +1,239 @@ +-- +goose Up +-- 管理后台角色权限管理:引入可自定义的角色实体 +-- +-- 背景:此前管理后台只有 super_admin / admin 两种身份,权限点直接绑在用户上 +-- (sys_admin_role_perms 名为 role 实为 user_perms),无法复用、无法按岗位批量调整, +-- 结果是全团队共用超管账号。本迁移补齐「角色」这一层。 +-- +-- 角色模型: +-- · 超级管理员不入本表 —— 它由 sys_admin_users.role='super_admin' 唯一判定并在鉴权时短路, +-- 特权判定只保留一个真相源;即便角色被删光,超管仍可登录并重建角色体系。 +-- · 三个预置角色(管理员/运营/技术支持)只是安装时的初始数据,不是系统契约: +-- 用户可以改权限、改名、禁用,也可以直接删掉自建一套。 +-- · 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms),只做并集不做否定。 +-- +-- 本迁移另含两笔独立变更:删除帮助中心不再使用的全文检索索引;新增 member:manage 权限点并回填存量授权。 + +-- ── 角色定义 ── +CREATE TABLE IF NOT EXISTS sys_admin_roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + name VARCHAR(50) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_roles_code UNIQUE (code) +); + +COMMENT ON TABLE sys_admin_roles IS '管理后台角色定义(超级管理员不入表,由 sys_admin_users.role 判定)'; +COMMENT ON COLUMN sys_admin_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_roles.code IS '角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)'; +COMMENT ON COLUMN sys_admin_roles.name IS '角色显示名称(可修改)'; +COMMENT ON COLUMN sys_admin_roles.description IS '角色说明,在分配界面展示'; +COMMENT ON COLUMN sys_admin_roles.is_builtin IS '是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)'; +COMMENT ON COLUMN sys_admin_roles.is_enabled IS '是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联'; +COMMENT ON COLUMN sys_admin_roles.sort IS '排序权重(升序)'; +COMMENT ON COLUMN sys_admin_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_roles.updated_at IS '更新时间'; + +-- ── 角色 → 权限点 ── +CREATE TABLE IF NOT EXISTS sys_admin_role_permissions ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_point VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_role_permissions UNIQUE (role_id, permission_point) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_role_permissions_role ON sys_admin_role_permissions (role_id); + +COMMENT ON TABLE sys_admin_role_permissions IS '角色权限点关联'; +COMMENT ON COLUMN sys_admin_role_permissions.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_role_permissions.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)'; +COMMENT ON COLUMN sys_admin_role_permissions.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; +COMMENT ON COLUMN sys_admin_role_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_role_permissions.updated_at IS '更新时间'; + +-- ── 用户 → 角色(多对多,覆盖兼岗场景) ── +CREATE TABLE IF NOT EXISTS sys_admin_user_roles ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_user_roles UNIQUE (admin_user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_user ON sys_admin_user_roles (admin_user_id); +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_role ON sys_admin_user_roles (role_id); + +COMMENT ON TABLE sys_admin_user_roles IS '管理员用户与角色关联(多对多:一个账号可同时担任多个角色,有效权限取并集)'; +COMMENT ON COLUMN sys_admin_user_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_user_roles.admin_user_id IS '管理员用户ID(逻辑关联 sys_admin_users.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_user_roles.updated_at IS '更新时间'; + +-- ── 存量表语义调整:sys_admin_role_perms 由「用户权限」改为「用户特批权限」 ── +-- 数据不迁移、不删除。它现在表示「在角色之外额外授予该用户的权限」, +-- 保留「临时给某人单加一个权限」的口子,不必为一个权限点新建角色。 +COMMENT ON TABLE sys_admin_role_perms IS '管理员用户特批权限(角色之外的额外授权,与角色权限取并集)'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '特批的权限点标识(独立于角色,角色禁用不影响特批)'; + +-- ── 预置角色种子 ── +-- ON CONFLICT DO NOTHING 有两层作用:① 迁移可重复执行; +-- ② 用户改过或删过的预置角色,重跑迁移不会被覆盖或复活。 +INSERT INTO sys_admin_roles (code, name, description, is_builtin, is_enabled, sort) VALUES + ('admin', '管理员', '二把手:能碰钱(退款)与系统配置(支付、汇率、存储、邮件),不能管理账号与角色', TRUE, TRUE, 10), + ('operator', '运营', '平台日常运营:渠道、模型、租户、套餐、内容的完整权限,不涉及资金与系统设置', TRUE, TRUE, 20), + ('support', '技术支持', '排查客户问题:除财务数据外全站只读,唯一写权限是工单、反馈与帮助中心', TRUE, TRUE, 30) +ON CONFLICT (code) DO NOTHING; + +-- 管理员:除 user:*(账号与角色)、system:update/plugin 外的全部权限 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view','channel:create','channel:edit','channel:test','channel:delete', + 'model:view','model:create','model:edit','model:delete', + 'tenant:view','tenant:create','tenant:edit','tenant:suspend','tenant:delete','tenant:close', + 'member:view','member:import','member:manage','member:model_scope', + 'plan:view','plan:create','plan:edit','plan:delete', + 'billing:view','billing:export','billing:refund', + 'order:view','order:refund', + 'promo:view','promo:create','promo:edit', + 'redemption:view','redemption:create','redemption:edit', + 'invoice:view','invoice:manage', + 'operation:view','operation:edit', + 'support:view','support:reply','support:edit', + 'monitor:view','monitor:edit', + 'task:view','task:edit', + 'file:view','file:delete','file:cleanup', + 'audit:view','audit:export', + 'system:view','system:edit' +]) +FROM sys_admin_roles r WHERE r.code = 'admin' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 运营:业务配置的完整权限;不给退款、审计、系统设置、账号管理 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view','channel:create','channel:edit','channel:test','channel:delete', + 'model:view','model:create','model:edit','model:delete', + 'tenant:view','tenant:create','tenant:edit','tenant:suspend', + 'member:view','member:import','member:manage','member:model_scope', + 'plan:view','plan:create','plan:edit','plan:delete', + 'billing:view', + 'order:view', + 'promo:view','promo:create','promo:edit', + 'redemption:view','redemption:create','redemption:edit', + 'invoice:view','invoice:manage', + 'operation:view','operation:edit', + 'support:view','support:reply','support:edit', + 'monitor:view','monitor:edit', + 'task:view','task:edit', + 'file:view','file:delete','file:cleanup' +]) +FROM sys_admin_roles r WHERE r.code = 'operator' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 技术支持:除财务面(billing/order/promo/redemption/invoice)外全站只读 + 工单完整权限。 +-- 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + 'dashboard:view', + 'channel:view', + 'model:view', + 'tenant:view', + 'member:view', + 'plan:view', + 'operation:view', + 'support:view','support:reply','support:edit', + 'monitor:view', + 'task:view', + 'file:view', + 'audit:view' +]) +FROM sys_admin_roles r WHERE r.code = 'support' +ON CONFLICT (role_id, permission_point) DO NOTHING; + + +-- 移除不再使用的配置项 +DELETE FROM sys_options +WHERE "key" IN ( + -- 安全 + 'max_sessions_per_user', + 'admin_max_sessions', + 'new_device_notification', + 'password_min_length', + -- 性能 + 'cache_enabled', + 'cache_ttl_seconds', + 'channel_affinity_ttl', + 'auto_test_interval', + -- 审计/数据治理 + 'audit_retention_days', + 'operation_log_retention_days', + 'data_deletion_completion_days', + -- 沙箱 + 'sandbox_enabled', + 'sandbox_default_quota' +); + + +-- ── 帮助中心搜索:删除不再使用的全文检索索引 ── +-- 搜索已从 to_tsvector 全文检索改为 ILIKE 模糊匹配:PostgreSQL 'simple' 分词配置不做中文分词, +-- 连续中文被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。 +-- 该 GIN 索引不再被查询使用,删除以释放空间。 +DROP INDEX IF EXISTS idx_spt_articles_search; + + +-- ── member:manage 权限点与存量回填 ── +-- 成员禁用/启用/重置密码/解锁四个写接口此前挂在只读的 member:view 上(internal/middleware/rbac.go), +-- 「技术支持」这类只读定位的角色可重置任意租户成员的密码 —— 改密等于接管该成员账号, +-- 进而可用其 API Key 消耗租户余额,档位与动作不匹配,已改为要求 member:manage。 +-- +-- 上方预置角色种子已直接带上 member:manage;下面两条回填覆盖「执行过早期版本本迁移 +-- (种子未含该权限点)」的部署:给【已持有 member:import 的角色/用户】补 member:manage —— +-- member:import 本就在 member 模块的操作档,持有它的角色已经能创建成员,补上「管理成员」 +-- 不扩大其实际能力边界;仅持 member:view 的只读角色(如预置 support)不补。 +-- 幂等:ON CONFLICT DO NOTHING,可重复执行。 + +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT DISTINCT rp.role_id, 'member:manage' +FROM sys_admin_role_permissions rp +WHERE rp.permission_point = 'member:import' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 用户特批权限同理:特批过 member:import 的账号一并补 member:manage, +-- 否则依赖特批权限工作的账号会在升级后突然失去成员管理能力。 +INSERT INTO sys_admin_role_perms (admin_user_id, permission_point) +SELECT DISTINCT p.admin_user_id, 'member:manage' +FROM sys_admin_role_perms p +WHERE p.permission_point = 'member:import' +ON CONFLICT DO NOTHING; + + +-- +goose Down +-- 移除 member:manage 权限点。回滚后 rbac.go 若仍要求 member:manage,相关接口对非超管一律 403, +-- 因此代码与迁移需要同步回退。 +DELETE FROM sys_admin_role_permissions WHERE permission_point = 'member:manage'; +DELETE FROM sys_admin_role_perms WHERE permission_point = 'member:manage'; + +-- 恢复帮助中心全文检索索引(仅回滚迁移时使用,此时搜索已退回 to_tsvector 方案) +CREATE INDEX idx_spt_articles_search ON spt_articles USING gin (to_tsvector('simple'::regconfig, (((COALESCE(title, ''::character varying))::text || ' '::text) || COALESCE(content, ''::text)))); + +-- 删除角色体系三张表。存量用户权限不受影响:它们本就存放在 sys_admin_role_perms, +-- 回滚后 HasPermission 退回「仅查用户特批权限」的旧语义。 +DROP TABLE IF EXISTS sys_admin_user_roles; +DROP TABLE IF EXISTS sys_admin_role_permissions; +DROP TABLE IF EXISTS sys_admin_roles; + +COMMENT ON TABLE sys_admin_role_perms IS '管理员角色权限关联'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; diff --git a/migrations/000021_v0_2_20_version.sql b/migrations/000021_v0_2_20_version.sql new file mode 100644 index 0000000..2052fbc --- /dev/null +++ b/migrations/000021_v0_2_20_version.sql @@ -0,0 +1,272 @@ +-- +goose Up +-- 定价存储 JSON 化 + 特殊计费 + 视频任务软删除 + 租户标签备注/钱包累计消费 + 模型厂商(由原 000021~000027 合并)。 +-- 设计文档:docs/模型定价存储JSON化与按秒计费设计.md、docs/租户标签备注与累计消费-执行规划.md +-- +-- mdl_pricing 一步收敛为「每模型一行 + pricing JSONB 唯一真相」: +-- * 旧价格列/多行档位回填进 JSON 后删除,model_id 加唯一约束; +-- * 新增 official_pricing 官方参考定价(管理端折扣计算的基准参照,非计费依据); +-- * billing_mode 值域新增 special(pricing JSONB 顶层 scheme 键声明计费方案,如 custom:minimax-material); +-- * 配套 bil_usage_logs.duration_seconds 落按秒计费任务时长,不塞 token 字段凑合。 +-- 另含四笔独立变更:tsk_model_tasks 软删除、tnt_tenants 标签/备注、bil_wallets 累计消费、mdl_models 厂商字段。 +-- ⚠️ 部署顺序强约束:必须先部署识别 special 的新代码,再执行本迁移—— +-- 旧代码读到 special 会按未配价 fail-closed 拒绝该模型的同步路径请求,且旧代码依赖的标量价格列在本迁移中被删除。 + +-- ── 一、tsk_model_tasks:OpenAI Videos DELETE 端点软删除 ── +-- 官方语义为「永久删除已完成/失败视频及其资产」,网关侧保留计费与审计记录, +-- 采用软删除(deleted=true 后客户端查询/下载均按 404 处理)。 +-- +goose StatementBegin +ALTER TABLE tsk_model_tasks + ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN deleted_at TIMESTAMPTZ NULL; +-- +goose StatementEnd + +COMMENT ON COLUMN tsk_model_tasks.deleted IS '软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)'; +COMMENT ON COLUMN tsk_model_tasks.deleted_at IS '软删除时间'; + +-- ── 二、mdl_pricing:收敛为每模型一行,pricing JSONB 唯一真相 ── +-- 保留列:model_id(加 UNIQUE)、billing_mode(可索引筛选)、pricing、official_pricing、 +-- price_note / discount_label / price_change_note。 + +-- 1. 锚点行去重(历史脏数据:个别模型存在多条 min_tokens=0 行)。 +-- 保留每组 id 最大的一条——与旧读取语义一致(GetModelPriceAt 全行扫描循环覆盖,后行胜出); +-- 去重先于回填,避免被删锚点行作为 min_tokens=0 档位混进聚合出的 tiers 数组、污染首档价格。 +-- +goose StatementBegin +DELETE FROM mdl_pricing p + USING mdl_pricing p2 + WHERE p.model_id = p2.model_id + AND p.min_tokens = 0 AND p2.min_tokens = 0 + AND p.id < p2.id; +-- +goose StatementEnd + +-- 2. 新增 pricing(唯一计费真相)与 official_pricing(官方参考定价)两列 +-- +goose StatementBegin +ALTER TABLE mdl_pricing + ADD COLUMN IF NOT EXISTS pricing JSONB, + ADD COLUMN IF NOT EXISTS official_pricing JSONB; +-- +goose StatementEnd + +COMMENT ON COLUMN mdl_pricing.pricing IS '计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)与 scheme(特殊计费方案)'; + +COMMENT ON COLUMN mdl_pricing.official_pricing IS '官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)'; + +-- 3. 存量回填:仅锚点行(min_tokens=0)写 pricing 列 +-- token / per_request:锚点行标量列构造 JSON +-- tiered:全部档位行聚合成 tiers 数组(锚点行承载 cache 价) +-- time_segments:并入 pricing 顶层 +-- +goose StatementBegin +WITH tiers AS ( + SELECT model_id, + jsonb_agg(jsonb_build_object( + 'min_tokens', min_tokens, 'max_tokens', max_tokens, + 'input_price', input_price, 'output_price', output_price + ) ORDER BY min_tokens) AS tier_arr + FROM mdl_pricing GROUP BY model_id +) +UPDATE mdl_pricing p SET pricing = + CASE p.billing_mode + WHEN 'per_request' THEN jsonb_build_object('price', COALESCE(p.per_request_price, 0)) + WHEN 'tiered' THEN jsonb_build_object( + 'tiers', t.tier_arr, + 'cache_read_price', p.cache_read_price, + 'cache_creation_price', p.cache_creation_price) + ELSE jsonb_build_object( + 'input_price', p.input_price, 'output_price', p.output_price, + 'cache_read_price', p.cache_read_price, + 'cache_creation_price', p.cache_creation_price) + END + || CASE WHEN p.time_segments IS NOT NULL + THEN jsonb_build_object('time_segments', p.time_segments) ELSE '{}'::jsonb END +FROM tiers t +WHERE t.model_id = p.model_id AND p.min_tokens = 0 AND p.pricing IS NULL; +-- +goose StatementEnd + +-- 4. 删除阶梯档位行(档位已聚合进锚点行 pricing->'tiers') +-- +goose StatementBegin +DELETE FROM mdl_pricing WHERE min_tokens <> 0; +-- +goose StatementEnd + +-- 5. 删除旧价格列与时段列(时段已并入 pricing 顶层) +-- +goose StatementBegin +ALTER TABLE mdl_pricing + DROP COLUMN IF EXISTS min_tokens, + DROP COLUMN IF EXISTS max_tokens, + DROP COLUMN IF EXISTS input_price, + DROP COLUMN IF EXISTS output_price, + DROP COLUMN IF EXISTS per_request_price, + DROP COLUMN IF EXISTS cache_read_price, + DROP COLUMN IF EXISTS cache_creation_price, + DROP COLUMN IF EXISTS time_segments; +-- +goose StatementEnd + +-- 6. 每模型一行唯一约束 +-- +goose StatementBegin +ALTER TABLE mdl_pricing ADD CONSTRAINT uk_mdl_pricing_model UNIQUE (model_id); +-- +goose StatementEnd + +-- 7. billing_mode 值域新增 special:pricing 顶层声明了 scheme 键的存量方案模型(如 MiniMax-H3 的 +-- custom:minimax-material 素材计费)从 per_second 改标 special,使用量日志/账单展示「特殊计费」 +-- 而非「按秒计费」。计费金额不受影响(计费分发只看 pricing JSONB 的 scheme 键,billing_mode 是 +-- 展示参考字段)。WHERE 带 billing_mode='per_second' 条件,可重复执行(幂等)。 +UPDATE mdl_pricing + SET billing_mode = 'special' + WHERE billing_mode = 'per_second' + AND pricing->>'scheme' IS NOT NULL + AND pricing->>'scheme' <> ''; + +COMMENT ON COLUMN mdl_pricing.billing_mode IS '计费模式:token=按量;per_request=按次;tiered=阶梯;per_second=按秒(视频);special=特殊计费(pricing JSONB 顶层 scheme 声明计费方案,如 custom:minimax-material)'; + +-- ── 三、bil_usage_logs:按秒计费任务时长 ── +-- +goose StatementBegin +ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS duration_seconds INT; +-- +goose StatementEnd + +COMMENT ON COLUMN bil_usage_logs.duration_seconds IS '按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL'; + +-- ── 四、tnt_tenants 标签备注 + bil_wallets 累计消费(方案 A:Redis 权威 + DB 物化副本)── +-- +goose StatementBegin +ALTER TABLE tnt_tenants + ADD COLUMN tags JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN remark VARCHAR(1000) NOT NULL DEFAULT ''; +-- +goose StatementEnd + +COMMENT ON COLUMN tnt_tenants.tags IS '租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)'; +COMMENT ON COLUMN tnt_tenants.remark IS '管理员备注(运营备注,不暴露给租户控制台)'; + +-- +goose StatementBegin +ALTER TABLE bil_wallets + ADD COLUMN total_consumed NUMERIC(20,10) NOT NULL DEFAULT 0; +-- +goose StatementEnd + +COMMENT ON COLUMN bil_wallets.total_consumed IS '累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)'; + +-- 存量回填:从流水账重放历史消费(consume 流水 amount 为负值),幂等可重跑 +UPDATE bil_wallets w +SET total_consumed = COALESCE(( + SELECT SUM(-t.amount) FROM bil_transactions t + WHERE t.tenant_id = w.tenant_id AND t.type = 'consume' +), 0); + +-- ── 五、mdl_models:研发厂商字段 ── +-- 注意与 chn_channels.type(渠道供应商/托管方)是两个维度:研发厂商指模型的开发公司, +-- 例如 Bedrock 上的 Claude 厂商仍是 anthropic,硅基流动上的 Qwen 厂商仍是 alibaba。 +-- +goose StatementBegin +ALTER TABLE mdl_models + ADD COLUMN vendor VARCHAR(30) NOT NULL DEFAULT ''; +-- +goose StatementEnd + +COMMENT ON COLUMN mdl_models.vendor IS '研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类'; + +-- 存量回填:按 model_id 前缀推断厂商(仅回填 vendor 为空的行,幂等可重跑;推断不准可在管理后台修正) +UPDATE mdl_models SET vendor = 'openai' WHERE vendor = '' AND (model_id LIKE 'gpt-%' OR model_id LIKE 'chatgpt-%' OR model_id LIKE 'o1%' OR model_id LIKE 'o3%' OR model_id LIKE 'o4%' OR model_id LIKE 'codex-%' OR model_id LIKE 'sora-%' OR model_id LIKE 'dall-e-%' OR model_id LIKE 'gpt-image%' OR model_id LIKE 'whisper-%' OR model_id LIKE 'tts-%' OR model_id LIKE 'text-embedding-%'); +UPDATE mdl_models SET vendor = 'anthropic' WHERE vendor = '' AND model_id LIKE 'claude-%'; +UPDATE mdl_models SET vendor = 'google' WHERE vendor = '' AND (model_id LIKE 'gemini-%' OR model_id LIKE 'veo-%' OR model_id LIKE 'imagen-%'); +UPDATE mdl_models SET vendor = 'xai' WHERE vendor = '' AND model_id LIKE 'grok-%'; +UPDATE mdl_models SET vendor = 'mistral' WHERE vendor = '' AND (model_id LIKE 'mistral-%' OR model_id LIKE 'codestral-%' OR model_id LIKE 'pixtral-%' OR model_id LIKE 'magistral-%'); +UPDATE mdl_models SET vendor = 'cohere' WHERE vendor = '' AND (model_id LIKE 'command-%' OR model_id LIKE 'embed-%' OR model_id LIKE 'rerank-%'); +UPDATE mdl_models SET vendor = 'meta' WHERE vendor = '' AND model_id LIKE 'llama-%'; +UPDATE mdl_models SET vendor = 'alibaba' WHERE vendor = '' AND (model_id LIKE 'qwen%' OR model_id LIKE 'qwq%' OR model_id LIKE 'qvq%'); +UPDATE mdl_models SET vendor = 'bytedance' WHERE vendor = '' AND (model_id LIKE 'doubao-%' OR model_id LIKE 'seedream-%' OR model_id LIKE 'seedance-%' OR model_id LIKE 'jimeng-%'); +UPDATE mdl_models SET vendor = 'deepseek' WHERE vendor = '' AND model_id LIKE 'deepseek-%'; +UPDATE mdl_models SET vendor = 'zhipu' WHERE vendor = '' AND model_id LIKE 'glm-%'; +UPDATE mdl_models SET vendor = 'moonshot' WHERE vendor = '' AND (model_id LIKE 'kimi-%' OR model_id LIKE 'moonshot-%'); +UPDATE mdl_models SET vendor = 'minimax' WHERE vendor = '' AND (model_id LIKE 'minimax-%' OR model_id LIKE 'abab-%'); +UPDATE mdl_models SET vendor = 'baidu' WHERE vendor = '' AND (model_id LIKE 'ernie-%' OR model_id LIKE 'wenxin-%'); +UPDATE mdl_models SET vendor = 'tencent' WHERE vendor = '' AND model_id LIKE 'hunyuan-%'; +UPDATE mdl_models SET vendor = 'xunfei' WHERE vendor = '' AND model_id LIKE 'spark-%'; +UPDATE mdl_models SET vendor = 'kuaishou' WHERE vendor = '' AND model_id LIKE 'kling-%'; +UPDATE mdl_models SET vendor = 'midjourney' WHERE vendor = '' AND (model_id LIKE 'mj_%' OR model_id LIKE 'midjourney-%'); +UPDATE mdl_models SET vendor = 'suno' WHERE vendor = '' AND model_id LIKE 'suno-%'; + +-- +goose Down +-- 逆序回滚:厂商 → 租户/钱包 → 用量时长 → 定价旧结构重建 → 视频软删除列。 + +-- ── 五、mdl_models:移除厂商字段 ── +-- +goose StatementBegin +ALTER TABLE mdl_models + DROP COLUMN IF EXISTS vendor; +-- +goose StatementEnd + +-- ── 四、tnt_tenants / bil_wallets ── +-- +goose StatementBegin +ALTER TABLE tnt_tenants + DROP COLUMN IF EXISTS tags, + DROP COLUMN IF EXISTS remark; +ALTER TABLE bil_wallets + DROP COLUMN IF EXISTS total_consumed; +-- +goose StatementEnd + +-- ── 三、bil_usage_logs ── +-- +goose StatementBegin +ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS duration_seconds; +-- +goose StatementEnd + +-- ── 二、mdl_pricing:从 pricing JSONB 无损反向重建旧结构 ── +-- JSON 自迁移起为唯一被写的真相,覆盖其后的全部改价。 +-- special 反向还原:scheme 模型收回 per_second(旧代码完全兼容形态); +-- 迁移后通过方案编辑器新保存的模型 scheme 非空,同样被覆盖回 per_second。 +UPDATE mdl_pricing + SET billing_mode = 'per_second' + WHERE billing_mode = 'special' + AND pricing->>'scheme' IS NOT NULL + AND pricing->>'scheme' <> ''; + +COMMENT ON COLUMN mdl_pricing.billing_mode IS '计费模式:token=按量;per_request=按次;tiered=阶梯;per_second=按秒(视频)'; + +-- +goose StatementBegin +ALTER TABLE mdl_pricing DROP CONSTRAINT IF EXISTS uk_mdl_pricing_model; +ALTER TABLE mdl_pricing + ADD COLUMN IF NOT EXISTS min_tokens BIGINT DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS max_tokens BIGINT, + ADD COLUMN IF NOT EXISTS input_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS output_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS per_request_price NUMERIC(20,10), + ADD COLUMN IF NOT EXISTS cache_read_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS cache_creation_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS time_segments JSONB; +-- +goose StatementEnd + +-- 锚点行标量列从 JSON 回写(tiered 取首档价,与旧行为一致) +-- +goose StatementBegin +UPDATE mdl_pricing SET + input_price = COALESCE((pricing->>'input_price')::numeric, (pricing->'tiers'->0->>'input_price')::numeric, 0), + output_price = COALESCE((pricing->>'output_price')::numeric, (pricing->'tiers'->0->>'output_price')::numeric, 0), + per_request_price = (pricing->>'price')::numeric, + cache_read_price = COALESCE((pricing->>'cache_read_price')::numeric, 0), + cache_creation_price = COALESCE((pricing->>'cache_creation_price')::numeric, 0), + time_segments = pricing->'time_segments' +WHERE pricing IS NOT NULL; +-- +goose StatementEnd + +-- tiered 档位行从 JSON tiers 数组展开重建(跳过首档——首档即锚点行 min_tokens=0) +-- +goose StatementBegin +INSERT INTO mdl_pricing (model_id, billing_mode, min_tokens, max_tokens, input_price, output_price, created_at, updated_at) +SELECT p.model_id, + p.billing_mode, + (t.tier->>'min_tokens')::bigint, + NULLIF(t.tier->>'max_tokens', '')::bigint, + COALESCE((t.tier->>'input_price')::numeric, 0), + COALESCE((t.tier->>'output_price')::numeric, 0), + now(), + now() +FROM mdl_pricing p + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.pricing->'tiers', '[]'::jsonb)) + WITH ORDINALITY AS t(tier, ord) +WHERE p.billing_mode = 'tiered' + AND p.pricing IS NOT NULL + AND (t.tier->>'min_tokens')::bigint <> 0 + AND t.ord > 1; +-- +goose StatementEnd + +-- JSON 列最后删除(上方旧结构重建均需读取 pricing) +-- +goose StatementBegin +ALTER TABLE mdl_pricing + DROP COLUMN IF EXISTS pricing, + DROP COLUMN IF EXISTS official_pricing; +-- +goose StatementEnd + +-- ── 一、tsk_model_tasks ── +-- +goose StatementBegin +ALTER TABLE tsk_model_tasks + DROP COLUMN IF EXISTS deleted, + DROP COLUMN IF EXISTS deleted_at; +-- +goose StatementEnd diff --git a/migrations/000022_v0_2_23_version.sql b/migrations/000022_v0_2_23_version.sql new file mode 100644 index 0000000..1f0085d --- /dev/null +++ b/migrations/000022_v0_2_23_version.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- ============================================================ +-- 日志表检索与排障优化 +-- +-- 覆盖 bil_usage_logs 与 aud_request_logs 两张日志表的索引补建 +-- 与排障字段补充,详见各小节说明。 +-- ============================================================ + + +CREATE INDEX IF NOT EXISTS idx_bil_usage_logs_created_bt + ON bil_usage_logs USING btree (created_at); + +ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS upstream_request_id VARCHAR(128) DEFAULT '' NOT NULL; +COMMENT ON COLUMN bil_usage_logs.upstream_request_id IS '上游请求ID:对话=上游响应头请求ID,任务=上游任务ID;排障反查用'; +CREATE INDEX IF NOT EXISTS idx_bil_usage_logs_upstream_request_id + ON bil_usage_logs USING btree (upstream_request_id); + + +ALTER TABLE aud_request_logs ADD COLUMN IF NOT EXISTS model_name VARCHAR(100); +COMMENT ON COLUMN aud_request_logs.model_name IS '请求使用的模型名(用户请求体中的原始模型,未经渠道映射)'; +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_model + ON aud_request_logs (model_name, created_at); +CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_bt + ON aud_request_logs USING btree (created_at); + + +-- +goose Down + +-- 四、删除审计日志 created_at 单列索引 +DROP INDEX IF EXISTS idx_aud_request_logs_created_bt; + +-- 三、删除审计日志模型字段(独立审计库需同步手工回滚) +DROP INDEX IF EXISTS idx_aud_request_logs_model; +ALTER TABLE aud_request_logs DROP COLUMN IF EXISTS model_name; + +-- 二、删除用量日志上游请求 ID 字段 +DROP INDEX IF EXISTS idx_bil_usage_logs_upstream_request_id; +ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS upstream_request_id; + +-- 一、删除用量日志 created_at 单列索引(分区父表删除会级联到各分区) +DROP INDEX IF EXISTS idx_bil_usage_logs_created_bt; 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/converter_test.go b/relay/channel/ali/converter_test.go index 5d835d5..5283cfa 100644 --- a/relay/channel/ali/converter_test.go +++ b/relay/channel/ali/converter_test.go @@ -127,8 +127,8 @@ func TestConvertRequest_ThinkingBudgetStripping(t *testing.T) { // 不影响既有的 top_p 裁剪行为。 func TestConvertRequest_TopPCappingRegression(t *testing.T) { body := map[string]json.RawMessage{ - "model": json.RawMessage(`"qwen-plus"`), - "top_p": json.RawMessage(`1.5`), + "model": json.RawMessage(`"qwen-plus"`), + "top_p": json.RawMessage(`1.5`), "thinking_budget": json.RawMessage(`128`), } requestBody, _ := json.Marshal(body) 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/native_stream_test.go b/relay/channel/claude/native_stream_test.go new file mode 100644 index 0000000..c9f11ed --- /dev/null +++ b/relay/channel/claude/native_stream_test.go @@ -0,0 +1,175 @@ +package claude + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// nativeStreamInfo 构造 Claude 原生透传(/v1/messages 直通)的 RelayInfo +func nativeStreamInfo() *common.RelayInfo { + return &common.RelayInfo{ + RelayMode: int(constant.RelayModeClaudeMessages), + IsStream: true, + InboundFormat: constant.RelayFormatClaude, + ClientFormat: constant.RelayFormatClaude, + OriginModelName: "claude-opus-4-8", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + BaseURL: "https://upstream.example.com", + UpstreamModelName: "claude-opus-4-8", + IsModelMapped: false, + }, + } +} + +func sseResponse(body io.Reader) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(body), + } +} + +// TestHandleClaudeNativeStream_FramesForwardedIntact 正常路径:上游 SSE 逐字节原样转发, +// 每个事件都带完整 data(客户端解码后不会出现空 data)。 +func TestHandleClaudeNativeStream_FramesForwardedIntact(t *testing.T) { + upstream := strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_01","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":0,"cache_creation_input_tokens":5161}}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`, + ``, + `event: message_stop`, + `data: {"type":"message_stop"}`, + ``, + ``, + }, "\n") + + a := &Adaptor{} + info := nativeStreamInfo() + rec := httptest.NewRecorder() + + usage, err := a.handleClaudeNativeStream(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleClaudeNativeStream error: %v", err) + } + + if got := rec.Body.String(); got != upstream { + t.Errorf("转发内容与上游不一致:\ngot: %q\nwant: %q", got, upstream) + } + if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonDone { + t.Errorf("EndReason = %q, want %q", reason, common.StreamEndReasonDone) + } + if usage == nil || usage.PromptTokens != 2 || usage.CompletionTokens != 7 || usage.CacheCreationTokens != 5161 { + t.Errorf("usage = %+v, want prompt=2 completion=7 cacheCreation=5161", usage) + } +} + +// TestHandleClaudeNativeStream_LastLineWithoutNewline 上游最后一行没有换行结尾时 +// 不得丢弃 —— ReadString 会同时返回「残留数据 + io.EOF」,旧实现直接 break 会吞掉这一行。 +func TestHandleClaudeNativeStream_LastLineWithoutNewline(t *testing.T) { + upstream := "event: message_stop\ndata: {\"type\":\"message_stop\"}" + + a := &Adaptor{} + info := nativeStreamInfo() + rec := httptest.NewRecorder() + + if _, err := a.handleClaudeNativeStream(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec); err != nil { + t.Fatalf("handleClaudeNativeStream error: %v", err) + } + + if got := rec.Body.String(); got != upstream { + t.Errorf("无换行结尾的最后一行被丢弃:\ngot: %q\nwant: %q", got, upstream) + } +} + +// TestHandleClaudeNativeStream_ClientGoneDiscardsPartialFrame 流中断时, +// 尚未攒齐的半帧必须丢弃,客户端只应看到完整的 error 帧 —— 否则半帧与 error 帧 +// 拼在一起会变成非法 SSE,客户端反而先在 JSON 解析上炸掉。 +func TestHandleClaudeNativeStream_ClientGoneDiscardsPartialFrame(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 吐出一个完整帧后取消 ctx,再交出半帧(只有 event 行):主循环把半帧收进缓冲、 + // 回到循环顶部时 select 命中 Done,模拟客户端在帧中途断开 + halfSent := false + body := io.MultiReader( + strings.NewReader("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":2,\"cache_creation_input_tokens\":5161}}}\n\n"), + readerFunc(func(p []byte) (int, error) { + if halfSent { + return 0, io.EOF + } + halfSent = true + cancel() + return copy(p, "event: content_block_delta\n"), nil + }), + ) + + a := &Adaptor{} + info := nativeStreamInfo() + rec := httptest.NewRecorder() + + usage, err := a.handleClaudeNativeStream(ctx, sseResponse(body), info, rec) + if !errors.Is(err, common.ErrStreamInterrupted) { + t.Fatalf("err = %v, want ErrStreamInterrupted", err) + } + if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonClientGone { + t.Errorf("EndReason = %q, want %q", reason, common.StreamEndReasonClientGone) + } + + got := rec.Body.String() + if strings.Contains(got, "content_block_delta") { + t.Errorf("半帧未被丢弃,泄漏到客户端:\n%q", got) + } + if !strings.Contains(got, `"type":"error"`) { + t.Errorf("未写出 error 帧,客户端会挂起:\n%q", got) + } + // 中断兜底仍要保留已拿到的 usage,避免按 0 token 结算 + if usage == nil || usage.PromptTokens != 2 || usage.CacheCreationTokens != 5161 { + t.Errorf("usage = %+v, want prompt=2 cacheCreation=5161", usage) + } +} + +// TestHandleClaudeNativeStream_UpstreamErrorDiscardsPartialFrame 上游读取出错时, +// 缓冲里的残留是不完整的帧,不得写给客户端。 +func TestHandleClaudeNativeStream_UpstreamErrorDiscardsPartialFrame(t *testing.T) { + upstreamErr := errors.New("unexpected EOF from upstream") + body := io.MultiReader( + strings.NewReader("event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: content_block_delta\n"), + readerFunc(func(p []byte) (int, error) { return 0, upstreamErr }), + ) + + a := &Adaptor{} + info := nativeStreamInfo() + rec := httptest.NewRecorder() + + _, err := a.handleClaudeNativeStream(context.Background(), sseResponse(body), info, rec) + if err == nil || !strings.Contains(err.Error(), "upstream stream interrupted") { + t.Fatalf("err = %v, want upstream stream interrupted", err) + } + if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonError { + t.Errorf("EndReason = %q, want %q", reason, common.StreamEndReasonError) + } + if got := rec.Body.String(); strings.Contains(got, "content_block_delta") { + t.Errorf("上游出错时半帧泄漏到客户端:\n%q", got) + } +} + +// readerFunc 把函数适配成 io.Reader +type readerFunc func(p []byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } diff --git a/relay/channel/claude/response.go b/relay/channel/claude/response.go index ebf4b24..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 原生格式响应 @@ -462,9 +171,25 @@ func (a *Adaptor) handleClaudeNativeStream(ctx context.Context, resp *http.Respo defer stopPing() reader := bufio.NewReaderSize(resp.Body, 64*1024) + // 按帧攒齐再写:逐行写会让并发的保活 ping 把自带空行插进帧中间,客户端据此派发出 + // 一个 data 为空的事件(JSON.parse("") 报错并中止请求),网关侧只看到 ctx 取消被记成 + // client_gone,真实成因被掩盖。SafeWriter 只保证单次 Write 原子,保证不了整帧原子。 + frame := helper.NewSSEFrameWriter(writer) var usage dto.ClaudeUsage var transferredTextLen int // 已转发的文本/思考内容长度,供流中断输出估算 + // onWriteFail 写客户端失败:立即关闭上游连接,停止 token 生成,按流中断结算 + onWriteFail := func(writeErr error) (*common.Usage, error) { + g.Log().Warningf(context.Background(), + "[ClaudeNativeStream] 写入客户端失败 request_id=%s writeErr=%v ctx.Err=%v elapsed=%v", + info.RequestID, writeErr, ctx.Err(), time.Since(info.StartTime)) + cleanup() + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, writeErr) + interruptedUsage := buildUsageFromClaude(&usage) + helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen) + return interruptedUsage, common.ErrStreamInterrupted + } + for { select { case <-streamCtx.Done(): @@ -472,6 +197,8 @@ func (a *Adaptor) handleClaudeNativeStream(ctx context.Context, resp *http.Respo // 让 SDK 收到"200 + SSE头 + 无事件 + EOF",Anthropic SDK 进入等待状态, // 后续请求的响应会被误判为当前流的 SSE 数据 → "Failed to parse JSON"。 // 发送一个 Claude 格式的 error event,让 SDK 以正常 API Error 退出,而非挂起。 + // 未完成的半帧尚未出网,先丢弃,避免与 error 帧拼成非法 SSE 输出。 + frame.Discard() _, _ = fmt.Fprintf(writer, "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"upstream disconnected\"}}\n\n") if f, ok := writer.(http.Flusher); ok { @@ -485,11 +212,13 @@ func (a *Adaptor) handleClaudeNativeStream(ctx context.Context, resp *http.Respo default: } + // ReadString 在上游 EOF 时可能同时返回「最后一段无换行的数据 + io.EOF」, + // 必须先处理 line 再判 err,否则会丢掉上游未以换行结尾的最后一行。 line, err := reader.ReadString('\n') - if err != nil { - if err == io.EOF { - break - } + + if err != nil && err != io.EOF { + // 上游读取出错:缓冲里的残留是不完整的帧,写给客户端只会造成非法 SSE,丢弃 + frame.Discard() info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) // 已有部分输出时按部分成功处理(避免标记为完全失败) interruptedUsage := buildUsageFromClaude(&usage) @@ -499,75 +228,71 @@ func (a *Adaptor) handleClaudeNativeStream(ctx context.Context, resp *http.Respo return interruptedUsage, fmt.Errorf("upstream stream interrupted: %w", err) } - if strings.HasPrefix(line, "data:") { - data, _ := helper.ExtractSSEData(line) + if line != "" { + if strings.HasPrefix(line, "data:") { + data, _ := helper.ExtractSSEData(line) - if data != "" && data != "[DONE]" { - info.SetFirstResponseTime() - } + if data != "" && data != "[DONE]" { + info.SetFirstResponseTime() + } - var event dto.ClaudeResponse - if json.Unmarshal([]byte(data), &event) != nil { - // JSON 解析失败:静默跳过 - } else { - switch event.Type { - case "message_start": - if event.Message != nil && event.Message.Usage != nil { - usage = *event.Message.Usage + var event dto.ClaudeResponse + if json.Unmarshal([]byte(data), &event) != nil { + // JSON 解析失败:静默跳过 + } else { + switch event.Type { + case "message_start": + if event.Message != nil && event.Message.Usage != nil { + usage = *event.Message.Usage + } + case "content_block_delta": + // 累计已转发文本长度,供流中断(message_delta 未到达时)输出估算 + if event.Delta != nil { + if event.Delta.Text != nil { + transferredTextLen += len(*event.Delta.Text) + } + if event.Delta.Thinking != nil { + transferredTextLen += len(*event.Delta.Thinking) + } + } + 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 "error": + info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("claude upstream stream error")) } - case "content_block_delta": - // 累计已转发文本长度,供流中断(message_delta 未到达时)输出估算 - if event.Delta != nil { - if event.Delta.Text != nil { - transferredTextLen += len(*event.Delta.Text) - } - if event.Delta.Thinking != nil { - transferredTextLen += len(*event.Delta.Thinking) - } - } - 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 "error": - info.StreamStatus.SetEndReason(common.StreamEndReasonError, fmt.Errorf("claude upstream stream error")) + } + + if info.ChannelMeta.IsModelMapped { + replaced := string(helper.ReplaceModelName([]byte(data), info.OriginModelName)) + line = fmt.Sprintf("data: %s\n", replaced) } } - if info.ChannelMeta.IsModelMapped { - replaced := string(helper.ReplaceModelName([]byte(data), info.OriginModelName)) - line = fmt.Sprintf("data: %s\n", replaced) + if werr := frame.WriteLine(line); werr != nil { + return onWriteFail(werr) } } - if _, err := writer.Write([]byte(line)); err != nil { - // 写入客户端失败:立即关闭上游连接,停止生成 - g.Log().Warningf(context.Background(), - "[ClaudeNativeStream] 写入客户端失败 request_id=%s writeErr=%v ctx.Err=%v elapsed=%v", - info.RequestID, err, ctx.Err(), time.Since(info.StartTime)) - cleanup() - info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, err) - interruptedUsage := buildUsageFromClaude(&usage) - helper.ApplyInterruptedUsageFallback(info, interruptedUsage, transferredTextLen) - return interruptedUsage, common.ErrStreamInterrupted - } - - if len(line) == 1 && line[0] == '\n' { - if f, ok := writer.(http.Flusher); ok { - f.Flush() + if err == io.EOF { + // 上游未以空行结尾时冲刷残留,避免丢掉最后一帧 + if ferr := frame.FlushPartial(); ferr != nil { + return onWriteFail(ferr) } + break } } 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..ce6752d 100644 --- a/relay/channel/deepseek/adaptor.go +++ b/relay/channel/deepseek/adaptor.go @@ -38,6 +38,12 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { } return betaURL + "/completions", nil case constant.RelayModeChatCompletions: + // chat_via_responses 桥接:请求体已由 relaykit 转成 Responses 格式,URL 必须同步 + // 切到 /v1/responses——否则 Responses 体打到 chat 端点,上游按 chat 格式反序列化 + // 直接 400(tools[0] missing field `function`) + if info.UseResponsesAPI { + return baseURL + "/v1/responses", nil + } return baseURL + "/v1/chat/completions", nil case constant.RelayModeClaudeMessages: return baseURL + "/anthropic/v1/messages", nil @@ -73,28 +79,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 +114,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 +132,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 +195,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 +216,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..8364fce 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" @@ -59,62 +59,75 @@ 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) { +// TestGetRequestURL_ChatViaResponsesBridge chat 入站 + chat_via_responses 桥接: +// 请求体已被 relaykit 转成 Responses 格式,URL 必须同步切到 /v1/responses—— +// 回归 chn_debug_logs #356:URL 不切时 Responses 体打到 chat 端点, +// 上游 400 "tools[0]: missing field `function`"。 +func TestGetRequestURL_ChatViaResponsesBridge(t *testing.T) { a := &Adaptor{} info := deepseekResponsesInfo(false) - out, err := a.ConvertRequest(context.Background(), info, []byte(deepseekResponsesRequestBody)) + info.RelayMode = int(constant.RelayModeChatCompletions) + info.InboundFormat = constant.RelayFormatOpenAI + info.ClientFormat = constant.RelayFormatOpenAI + info.ChannelMeta.ChatViaResponses = true + info.UseResponsesAPI = true + + got, err := a.GetRequestURL(info) if err != nil { - t.Fatalf("ConvertRequest error: %v", err) + t.Fatalf("GetRequestURL error: %v", err) + } + if want := "https://api.deepseek.example.com/v1/responses"; got != want { + t.Errorf("chat via responses bridge: URL = %q, want %q", got, want) + } + + // 未开桥接的 chat 请求仍走 chat 端点 + info.UseResponsesAPI = false + got, err = a.GetRequestURL(info) + if err != nil { + t.Fatalf("GetRequestURL error: %v", err) + } + if want := "https://api.deepseek.example.com/v1/chat/completions"; got != want { + t.Errorf("plain chat: URL = %q, want %q", got, want) + } +} + +const deepseekResponsesRequestBody = `{"model":"deepseek-v4-flash","instructions":"You are helpful.","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}],"stream":true}` + +// 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) + _, 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("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..5b2bc74 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) @@ -509,10 +480,11 @@ func appendToolCall(toolCalls []dto.ToolCall, idx *int, part dto.GeminiPart) []d return toolCalls } argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) + i := *idx tc := dto.ToolCall{ ID: fmt.Sprintf("call_%d", *idx), Type: "function", - Index: *idx, + Index: &i, Function: dto.FunctionCall{ Name: part.FunctionCall.FunctionName, Arguments: string(argsJSON), 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 61d6ab4..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" @@ -100,11 +99,17 @@ func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Respo isCA := a.isCodeAssistActive() reader := bufio.NewReader(resp.Body) + // 按帧攒齐再写:本路径当前没有并发的保活 ping,但逐行写既不是帧原子(将来接入 ping + // 或多行 data 时会被空行劈开),也会按行 Flush 产生多余系统调用;统一走帧写入器。 + frame := helper.NewSSEFrameWriter(writer) + var totalUsage dto.GeminiUsageMetadata for { select { case <-ctx.Done(): + // 未完成的半帧尚未出网,丢弃即可 + frame.Discard() info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) // 流中断:返回已累计的 usage(Gemini 每个 chunk 携带累计值),输入缺失用请求侧估算补齐 interruptedUsage := geminiUsageToCommon(&totalUsage) @@ -127,10 +132,12 @@ func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Respo } if data == "[DONE]" { - _, _ = io.WriteString(writer, outputLine) - if f, ok := writer.(http.Flusher); ok { - f.Flush() + if werr := frame.WriteLine(outputLine); werr != nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, werr) + break } + // [DONE] 后直接收尾,须冲刷缓冲,否则这一行发不出去 + _ = frame.FlushPartial() break } @@ -153,17 +160,21 @@ func (a *Adaptor) handleGeminiNativeStream(ctx context.Context, resp *http.Respo } } - // 输出 SSE 行(解包后的) - _, _ = io.WriteString(writer, outputLine) - if f, ok := writer.(http.Flusher); ok { - f.Flush() + // 输出 SSE 行(解包后的),遇帧分隔空行整帧写出 + if werr := frame.WriteLine(outputLine); werr != nil { + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, werr) + break } } if err == io.EOF { + // 上游未以空行结尾时冲刷残留,避免丢掉最后一帧 + _ = frame.FlushPartial() break } if err != nil { + // 上游读取出错:缓冲里的残留是不完整的帧,写给客户端只会造成非法 SSE,丢弃 + frame.Discard() info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) break } @@ -188,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 格式 @@ -240,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/response_test.go b/relay/channel/gemini/response_test.go index 1935f81..d898c60 100644 --- a/relay/channel/gemini/response_test.go +++ b/relay/channel/gemini/response_test.go @@ -1,12 +1,147 @@ package gemini import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" "strings" "testing" + "time" + "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/dto" ) +// nativeStreamInfo 构造 Gemini 原生透传(/v1beta 直通)的 RelayInfo +func nativeStreamInfo() *common.RelayInfo { + return &common.RelayInfo{ + IsStream: true, + OriginModelName: "gemini-3-pro", + StartTime: time.Now(), + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gemini-3-pro", + }, + } +} + +func sseResponse(body io.Reader) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(body), + } +} + +// sseFrameRecorder 记录每次 Write 的内容,用于断言「一次 Write = 一个完整 SSE 帧」 +type sseFrameRecorder struct { + header http.Header + chunks []string + body strings.Builder +} + +func (r *sseFrameRecorder) Header() http.Header { + if r.header == nil { + r.header = http.Header{} + } + return r.header +} + +func (r *sseFrameRecorder) WriteHeader(int) {} + +func (r *sseFrameRecorder) Write(p []byte) (int, error) { + r.chunks = append(r.chunks, string(p)) + r.body.Write(p) + return len(p), nil +} + +func (r *sseFrameRecorder) Flush() {} + +// TestHandleGeminiNativeStream_FramesForwardedIntact 正常路径:内容逐字节原样转发, +// 且每次 Write 都落在帧边界(帧原子,将来接入保活 ping 也不会被空行劈开)。 +func TestHandleGeminiNativeStream_FramesForwardedIntact(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"candidates":[{"content":{"parts":[{"text":"你好"}]}}]}`, + ``, + `data: {"candidates":[{"content":{"parts":[{"text":"世界"}]}}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":7,"totalTokenCount":10}}`, + ``, + ``, + }, "\n") + + info := nativeStreamInfo() + a := &Adaptor{} + a.Init(info) + rec := &sseFrameRecorder{} + + usage, err := a.handleGeminiNativeStream(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec) + if err != nil { + t.Fatalf("handleGeminiNativeStream error: %v", err) + } + + if got := rec.body.String(); got != upstream { + t.Errorf("转发内容与上游不一致:\ngot: %q\nwant: %q", got, upstream) + } + for i, c := range rec.chunks { + if !strings.HasSuffix(c, "\n\n") { + t.Errorf("第 %d 次 Write 未落在帧边界:\n%q", i, c) + } + } + if usage == nil || usage.PromptTokens != 3 || usage.CompletionTokens != 7 { + t.Errorf("usage = %+v, want prompt=3 completion=7", usage) + } +} + +// TestHandleGeminiNativeStream_UpstreamErrorDiscardsPartialFrame 上游读取出错时, +// 缓冲里的残留是不完整的帧,不得写给客户端 —— 旧实现逐行写会把半行/半帧留在连接上。 +func TestHandleGeminiNativeStream_UpstreamErrorDiscardsPartialFrame(t *testing.T) { + upstreamErr := errors.New("unexpected EOF from upstream") + body := io.MultiReader( + strings.NewReader(`data: {"candidates":[{"content":{"parts":[{"text":"你好"}]}}]}`+"\n\n"+`data: {"candidates":[`+"\n"), + readerFunc(func(p []byte) (int, error) { return 0, upstreamErr }), + ) + + info := nativeStreamInfo() + a := &Adaptor{} + a.Init(info) + rec := httptest.NewRecorder() + + if _, err := a.handleGeminiNativeStream(context.Background(), sseResponse(body), info, rec); err != nil { + t.Fatalf("handleGeminiNativeStream error: %v", err) + } + if reason := info.StreamStatus.GetEndReason(); reason != common.StreamEndReasonError { + t.Errorf("EndReason = %q, want %q", reason, common.StreamEndReasonError) + } + if got := rec.Body.String(); strings.Contains(got, `{"candidates":[`+"\n") { + t.Errorf("上游出错时半帧泄漏到客户端:\n%q", got) + } +} + +// TestHandleGeminiNativeStream_LastFrameWithoutBlankLine 上游未以空行结尾时, +// 最后一帧必须冲刷出去,不能滞留在帧缓冲里。 +func TestHandleGeminiNativeStream_LastFrameWithoutBlankLine(t *testing.T) { + upstream := `data: {"candidates":[{"content":{"parts":[{"text":"末帧"}]}}]}` + + info := nativeStreamInfo() + a := &Adaptor{} + a.Init(info) + rec := httptest.NewRecorder() + + if _, err := a.handleGeminiNativeStream(context.Background(), sseResponse(strings.NewReader(upstream)), info, rec); err != nil { + t.Fatalf("handleGeminiNativeStream error: %v", err) + } + if got := rec.Body.String(); got != upstream { + t.Errorf("无空行结尾的最后一帧被吞掉:\ngot: %q\nwant: %q", got, upstream) + } +} + +// readerFunc 把函数适配成 io.Reader +type readerFunc func(p []byte) (int, error) + +func (f readerFunc) Read(p []byte) (int, error) { return f(p) } + // TestBuildGeminiUpstreamError_ResourceExhausted 验证真实 Gemini 429(区域配额耗尽) // 被解析为携带正确 type 的 RelayError,且 message 为简短文案而非整个 body。 // 回归保护:OpenAI 出站路径上游报错时,由上层写入器写出单条 rate_limit_error, 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..025f2bf 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 格式 @@ -620,8 +470,9 @@ func legacyOllamaStreamToolCalls(toolCalls []OllamaToolCall, nextIndex *int) []d argsBytes = b } } + idx := *nextIndex out = append(out, dto.ToolCall{ - Index: *nextIndex, + Index: &idx, ID: id, Type: "function", Function: dto.FunctionCall{ 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 7d62863..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{ @@ -260,6 +236,81 @@ func TestAdaptor_DoResponse_ResponsesUpstreamStream(t *testing.T) { } } +// sseFrameRecorder 记录每次 Write 的内容,用于断言「一次 Write = 一个完整 SSE 帧」。 +// 帧原子是保活 ping 不会劈开帧的前提:只要每次 Write 都落在帧边界上, +// 并发 ping 自带的空行就只能插在帧与帧之间,客户端不会派发出 data 为空的事件。 +type sseFrameRecorder struct { + header http.Header + chunks []string + body strings.Builder +} + +func (r *sseFrameRecorder) Header() http.Header { + if r.header == nil { + r.header = http.Header{} + } + return r.header +} + +func (r *sseFrameRecorder) WriteHeader(int) {} + +func (r *sseFrameRecorder) Write(p []byte) (int, error) { + r.chunks = append(r.chunks, string(p)) + r.body.Write(p) + return len(p), nil +} + +func (r *sseFrameRecorder) Flush() {} + +// TestAdaptor_DoResponse_ResponsesUpstreamStream_FrameAtomic Responses 透传必须按帧写出。 +// 该协议每个事件都是 `event:` + `data:` 两行,旧实现把两行分成两次 Write(且 event 行不 Flush), +// 并发的保活 ping 会把空行插进两行之间 —— 客户端派发出 data 为空的事件,JSON.parse("") 抛错 +// 并中止请求,网关侧只看到 ctx 取消、被记成 client_gone。 +func TestAdaptor_DoResponse_ResponsesUpstreamStream_FrameAtomic(t *testing.T) { + ss := strings.Join([]string{ + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"Hello"}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`, + "", + "data: [DONE]", + "", + }, "\n") + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(ss)), + } + + info := responsesUpstreamInfo(constant.RelayModeResponses, true) + rec := &sseFrameRecorder{} + a := &Adaptor{} + if _, err := a.DoResponse(context.Background(), resp, info, rec); err != nil { + t.Fatalf("DoResponse error: %v", err) + } + + if len(rec.chunks) == 0 { + t.Fatal("没有任何写出") + } + for i, c := range rec.chunks { + if !strings.HasSuffix(c, "\n\n") { + t.Errorf("第 %d 次 Write 未落在帧边界,保活 ping 可插入其中:\n%q", i, c) + } + } + + out := rec.body.String() + // [DONE] 必须是完整终止帧:只写 data 行会把它留在缓冲里发不出去 + if !strings.HasSuffix(out, "data: [DONE]\n\n") { + t.Errorf("[DONE] 帧未以空行终止:\n%q", out) + } + for _, want := range []string{"event: response.output_text.delta", `"delta":"Hello"`, "event: response.completed"} { + if !strings.Contains(out, want) { + t.Errorf("stream output missing %q\n---\n%s", want, out) + } + } +} + // fakeResponseRouteStore 路由存储 fake,用于断言 Record 调用 type fakeResponseRouteStore struct { calls []fakeRouteCall @@ -281,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 @@ -530,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 { @@ -603,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。 @@ -654,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()) @@ -767,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()) @@ -810,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 5a6a5c7..95bfe30 100644 --- a/relay/channel/openai/claude_response.go +++ b/relay/channel/openai/claude_response.go @@ -1,21 +1,93 @@ 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" ) +// 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() @@ -27,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 @@ -66,425 +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 事件 -func writeClaudeSSE(w http.ResponseWriter, eventType string, data any) { - dataJSON, _ := json.Marshal(data) - _ = helper.WriteSSEEvent(w, eventType, string(dataJSON)) -} - -// 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 645959c..f10bcb2 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 响应 @@ -1364,16 +1134,18 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info toolCallNameByID := make(map[string]string) toolCallArgsByID := make(map[string]string) toolCallNameSent := make(map[string]bool) + // item_id → call_id 映射:function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID) + // 与 call_id 是两个不同的值,而 arguments 增量事件只带 item_id。不做映射会把同一 + // 工具调用拆成两个 index,客户端在无 name 的新 index 上报错 + callIDByItemID := make(map[string]string) sendChatChunk := func(chunk *dto.ChatCompletionStreamResponse) bool { if chunk == nil { return true } - data, err := json.Marshal(chunk) - if err != nil { - return false - } - return helper.WriteSSEData(writer, string(data)) == nil + // 池化缓冲 + 绑定 encoder:序列化与拼帧零分配,整帧单次写出。 + // 序列化失败与写失败都返回 false(同旧行为),由调用方终止流。 + return helper.WriteSSEDataJSON(writer, chunk) == nil } sendStartIfNeeded := func() bool { @@ -1458,6 +1230,9 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info if callID == "" { return } + if itemID := strings.TrimSpace(streamResp.Item.ID); itemID != "" { + callIDByItemID[itemID] = callID + } name := strings.TrimSpace(streamResp.Item.Name) if name != "" { toolCallNameByID[callID] = name @@ -1483,10 +1258,14 @@ func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info case "response.function_call_arguments.delta": itemID := strings.TrimSpace(streamResp.ItemID) - callID := itemID - if callID == "" { + if itemID == "" { return } + // 增量事件只带 item_id,先映射回 call_id(无 added 事件先行的上游按原值兜底) + callID := callIDByItemID[itemID] + if callID == "" { + callID = itemID + } toolCallArgsByID[callID] += streamResp.Delta if !r2cSendToolCallChunk(responseID, createAt, model, callID, "", streamResp.Delta, toolCallIndexByID, toolCallNameByID, toolCallNameSent, writer, sendChatChunk) { sr.Stop(fmt.Errorf("send tool call args chunk failed")) @@ -1592,7 +1371,7 @@ func r2cSendToolCallChunk(responseID string, createAt int64, model string, callI if nameByID[callID] != "" { name = nameByID[callID] } - tool := dto.ToolCall{ID: callID, Type: "function", Index: idx, Function: dto.FunctionCall{Arguments: argsDelta}} + 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 @@ -1648,3 +1427,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 64729a1..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,306 +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 { - chunkData, _ := json.Marshal(geminiChunk) - _ = helper.WriteSSEData(writer, string(chunkData)) - } + // 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 - } - } - chunkData, _ := json.Marshal(finalChunk) - _ = helper.WriteSSEData(writer, string(chunkData)) - - 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 new file mode 100644 index 0000000..951a8d2 --- /dev/null +++ b/relay/channel/openai/pooled_sse_stream_test.go @@ -0,0 +1,211 @@ +package openai + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/dto" +) + +// 本文件覆盖两条改用池化帧写入器(helper.WriteSSEDataJSON)的转换路径: +// - handleGeminiInboundStream:OpenAI 上游 → Gemini 客户端 +// - HandleResponsesStreamToChat:Responses 上游 → Chat Completions 客户端 +// +// 两条路径此前无测试覆盖。断言两件事: +// 1. 输出仍是合法且内容正确的 SSE(池化不能改变对外协议); +// 2. 每次 Write 都落在帧边界(整帧单次写出,并发保活 ping 插不进帧中间)。 + +// assertFramesAreSingleWrites 断言每次 Write 都是一个以空行收尾的完整帧 +func assertFramesAreSingleWrites(t *testing.T, chunks []string) { + t.Helper() + if len(chunks) == 0 { + t.Fatal("没有任何写出") + } + for i, c := range chunks { + if !strings.HasSuffix(c, "\n\n") { + t.Errorf("第 %d 次 Write 未落在帧边界,保活 ping 可插入其中:\n%q", i, c) + } + } +} + +// collectSSEDataFrames 从 SSE 文本中取出所有 data 行的内容 +func collectSSEDataFrames(raw string) []string { + var out []string + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "data:") { + out = append(out, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + return out +} + +func geminiInboundInfo() *common.RelayInfo { + return &common.RelayInfo{ + IsStream: true, + InboundFormat: constant.RelayFormatGemini, + ClientFormat: constant.RelayFormatGemini, + OriginModelName: "gemini-3-pro", + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gpt-4o", + }, + } +} + +// TestHandleGeminiInboundStream_PooledFramesValid OpenAI SSE → Gemini SSE: +// 每个 chunk 都是完整可解析的 Gemini JSON,且整帧单次写出。 +func TestHandleGeminiInboundStream_PooledFramesValid(t *testing.T) { + upstream := strings.Join([]string{ + `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"你好"}}]}`, + ``, + `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"世界"}}]}`, + ``, + `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstream)), + } + + info := geminiInboundInfo() + rec := &sseFrameRecorder{} + + usage, err := handleGeminiInboundStream(context.Background(), resp, info, rec) + if err != nil { + t.Fatalf("handleGeminiInboundStream error: %v", err) + } + + assertFramesAreSingleWrites(t, rec.chunks) + + frames := collectSSEDataFrames(rec.body.String()) + if len(frames) < 2 { + t.Fatalf("data 帧数 = %d, 期望至少 2(内容 chunk + 终止 chunk)\n%s", len(frames), rec.body.String()) + } + + var text strings.Builder + for i, f := range frames { + if f == "[DONE]" { + // 真实 Gemini API 无 [DONE] 哨兵;官方 SDK 对每个 data 帧做 JSON.parse, + // 写 [DONE] 会直接抛 SyntaxError 导致客户端报错 + t.Errorf("Gemini 客户端不得收到 [DONE](第 %d 帧)", i) + continue + } + var chunk dto.GeminiChatResponse + if err := json.Unmarshal([]byte(f), &chunk); err != nil { + t.Fatalf("第 %d 帧不是合法 Gemini JSON(池化若出错会产生这种残片): %v\ndata=%q", i, err, f) + } + for _, cand := range chunk.Candidates { + if cand.Content == nil { + continue + } + for _, p := range cand.Content.Parts { + text.WriteString(p.Text) + } + } + } + // 收尾 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, "你好世界") + } + if usage == nil || usage.PromptTokens != 11 || usage.CompletionTokens != 7 { + t.Errorf("usage = %+v, want prompt=11 completion=7", usage) + } +} + +func responsesToChatInfo() *common.RelayInfo { + return &common.RelayInfo{ + IsStream: true, + InboundFormat: constant.RelayFormatOpenAI, + ClientFormat: constant.RelayFormatOpenAI, + OriginModelName: "gpt-4o", + StreamStatus: common.NewStreamStatus(), + ChannelMeta: &common.ChannelMeta{ + ChannelType: int(constant.ProviderOpenAI), + BaseURL: "https://upstream.example.com", + UpstreamModelName: "gpt-4o", + SupportsResponses: true, + }, + } +} + +// TestHandleResponsesStreamToChat_PooledFramesValid Responses SSE → Chat SSE: +// 每个 chunk 都是完整可解析的 chat.completion.chunk,且整帧单次写出。 +func TestHandleResponsesStreamToChat_PooledFramesValid(t *testing.T) { + upstream := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress"}}`, + ``, + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"你好"}`, + ``, + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"世界"}`, + ``, + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","usage":{"input_tokens":11,"output_tokens":7,"total_tokens":18}}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(upstream)), + } + + info := responsesToChatInfo() + rec := &sseFrameRecorder{} + + if _, err := HandleResponsesStreamToChat(context.Background(), resp, info, rec); err != nil { + t.Fatalf("HandleResponsesStreamToChat error: %v", err) + } + + assertFramesAreSingleWrites(t, rec.chunks) + + frames := collectSSEDataFrames(rec.body.String()) + var text strings.Builder + sawDone := false + for i, f := range frames { + if f == "[DONE]" { + sawDone = true + continue + } + var chunk dto.ChatCompletionStreamResponse + if err := json.Unmarshal([]byte(f), &chunk); err != nil { + t.Fatalf("第 %d 帧不是合法 chat.completion.chunk: %v\ndata=%q", i, err, f) + } + if chunk.Object != "chat.completion.chunk" { + t.Errorf("第 %d 帧 object = %q, want chat.completion.chunk", i, chunk.Object) + } + for _, c := range chunk.Choices { + if s, ok := c.Delta.Content.(string); ok { + text.WriteString(s) + } + } + } + if !sawDone { + t.Error("缺少 [DONE] 终止帧") + } + 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 11f7165..986b67f 100644 --- a/relay/channel/openai/responses.go +++ b/relay/channel/openai/responses.go @@ -2,9 +2,9 @@ package openai import ( "bufio" - "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -17,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() @@ -41,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, @@ -72,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 @@ -104,570 +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 桥接复用) -func EmitResponsesSSE(w http.ResponseWriter, eventType string, data any) { - jsonData, err := json.Marshal(data) - if err != nil { - return - } - fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, string(jsonData)) - if f, ok := w.(http.Flusher); ok { - f.Flush() - } -} - // buildResponsesObjectMap 构建 Responses API response 对象的完整字段 map。 // 请求参数从 info.ResponsesRequest echo(快照缺失时回退默认值); // store 恒为 false——合成响应不落上游存储,客户端不可经生命周期端点 retrieve。 @@ -851,18 +318,29 @@ func (a *Adaptor) handleResponsesUpstreamStream(ctx context.Context, resp *http. buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 10*1024*1024) + // 按帧攒齐再写:Responses 的每个事件都是 `event:` + `data:` 两行,逐行写会让并发的 + // 保活 ping 把自带空行插进两行之间,客户端据此派发出一个 data 为空的事件 + //(JSON.parse("") 报错并中止请求),网关侧只看到 ctx 取消被记成 client_gone。 + frame := helper.NewSSEFrameWriter(writer) + var usage common.Usage var contentBuilder strings.Builder - flush := func() { - if f, ok := writer.(http.Flusher); ok { - f.Flush() - } + // onWriteFail 写客户端失败:客户端已不可达,按流中断结算 + onWriteFail := func(writeErr error) (*common.Usage, error) { + g.Log().Warningf(context.Background(), + "[ResponsesUpstreamStream] 写入客户端失败 request_id=%s writeErr=%v ctx.Err=%v", + info.RequestID, writeErr, ctx.Err()) + info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, writeErr) + helper.ApplyInterruptedUsageFallback(info, &usage, contentBuilder.Len()) + return &usage, common.ErrStreamInterrupted } for scanner.Scan() { select { case <-ctx.Done(): + // 未完成的半帧尚未出网,丢弃即可 + frame.Discard() info.StreamStatus.SetEndReason(common.StreamEndReasonClientGone, ctx.Err()) // 流中断计费兜底:输出缺失按已转发文本 2 字符/token 估算,输入用请求侧估算值补齐 helper.ApplyInterruptedUsageFallback(info, &usage, contentBuilder.Len()) @@ -872,13 +350,16 @@ func (a *Adaptor) handleResponsesUpstreamStream(ctx context.Context, resp *http. line := scanner.Text() if line == "" { - fmt.Fprintf(writer, "\n") - flush() + if err := frame.WriteLine("\n"); err != nil { + return onWriteFail(err) + } continue } // 原样透传 event: 行 if strings.HasPrefix(line, "event:") { - fmt.Fprintf(writer, "%s\n", line) + if err := frame.WriteLine(line + "\n"); err != nil { + return onWriteFail(err) + } continue } if !strings.HasPrefix(line, "data:") { @@ -918,14 +399,25 @@ func (a *Adaptor) handleResponsesUpstreamStream(ctx context.Context, resp *http. outLine = "data: " + string(helper.ReplaceModelName([]byte(data), info.OriginModelName)) } - fmt.Fprintf(writer, "%s\n", outLine) - flush() + if err := frame.WriteLine(outLine + "\n"); err != nil { + return onWriteFail(err) + } if data == "[DONE]" { + // 补上帧分隔空行再收尾:只写 data 行会把 [DONE] 留在缓冲里发不出去, + // 客户端也拿不到完整的终止帧 + if err := frame.WriteLine("\n"); err != nil { + return onWriteFail(err) + } break } } + // 上游未以空行结尾时冲刷残留,避免丢掉最后一帧 + if err := frame.FlushPartial(); err != nil { + return onWriteFail(err) + } + if err := scanner.Err(); err != nil { if err != io.EOF && ctx.Err() == nil { info.StreamStatus.SetEndReason(common.StreamEndReasonError, err) 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/volcengine/convert_test.go b/relay/channel/volcengine/convert_test.go index 337e921..e5de359 100644 --- a/relay/channel/volcengine/convert_test.go +++ b/relay/channel/volcengine/convert_test.go @@ -15,10 +15,10 @@ func TestOpenaiAdaptorStreamOptionsInjection(t *testing.T) { body := []byte(`{"model":"doubao-pro","messages":[{"role":"user","content":"hi"}],"stream":true}`) tests := []struct { - name string - mode constant.RelayMode - isStream bool - wantInj bool + name string + mode constant.RelayMode + isStream bool + wantInj bool }{ {"流式 chat 注入 stream_options", constant.RelayModeChatCompletions, true, true}, {"非流式 chat 不注入", constant.RelayModeChatCompletions, false, false}, 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/debug_session.go b/relay/common/debug_session.go index 14efa63..3b516f4 100644 --- a/relay/common/debug_session.go +++ b/relay/common/debug_session.go @@ -66,6 +66,24 @@ func (s *DebugSession) BeginAttempt(selection *ChannelSelection, modelName, rela } } +// BeginTaskAttempt 为异步任务提交创建捕获上下文。任务提交为单次尝试(无重试循环), +// isStream 恒 false,渠道元信息取 ChannelMeta(任务链路经 selectTaskChannel 物化, +// 无 ChannelSelection)。上游请求经适配器 DoRequest(ctx) 携带的捕获器镜像段2/3。 +func (s *DebugSession) BeginTaskAttempt(meta *ChannelMeta, modelName, relayModeStr string, retryIndex int) *DebugAttempt { + return &DebugAttempt{ + session: s, + Capture: &DebugAttemptCapture{}, + channelID: meta.ChannelID, + channelName: meta.ChannelName, + channelType: meta.ChannelType, + modelName: modelName, + upstreamModel: meta.UpstreamModelName, + relayMode: relayModeStr, + isStream: false, + retryIndex: retryIndex, + } +} + // DebugAttempt 单次尝试的调试上下文:会话引用 + 渠道元数据 + 传输层捕获器。 // Capture 经 WithDebugAttempt 挂入 ctx,由 DebugRoundTripper 填充段2/段3。 type DebugAttempt struct { diff --git a/relay/common/debug_session_test.go b/relay/common/debug_session_test.go index 34dd584..f7d4e8c 100644 --- a/relay/common/debug_session_test.go +++ b/relay/common/debug_session_test.go @@ -116,6 +116,50 @@ func TestDebugSessionNilSafe(t *testing.T) { a.MarkFinal(nil) // 不应 panic } +// 任务版构造:ChannelMeta 字段映射、isStream 恒 false; +// MarkFinal + FinalizeAndSubmit 后提交带段4 的最终记录(任务提交单次尝试形态) +func TestBeginTaskAttempt(t *testing.T) { + var records []*DebugLogRecord + origHook := SubmitDebugLog + SubmitDebugLog = func(ctx context.Context, r *DebugLogRecord) { records = append(records, r) } + defer func() { SubmitDebugLog = origHook }() + + s := NewDebugSession("req-task", 1, 2, 3, "/v1/videos") + meta := &ChannelMeta{ChannelID: 7, ChannelName: "ch7", ChannelType: int(constant.ProviderKling), UpstreamModelName: "kling-v2"} + + a := s.BeginTaskAttempt(meta, "kling-v2-master", "videos", 0) + if a.isStream { + t.Error("任务尝试 isStream 应恒为 false") + } + + // 段4 writer(任务提交响应,非流式 JSON) + rec := httptest.NewRecorder() + dw := NewDebugClientWriter(rec) + s.SetClientWriter(dw) + dw.WriteHeader(200) + _, _ = dw.Write([]byte(`{"id":"video_x"}`)) + + a.MarkFinal(nil) + s.FinalizeAndSubmit(88, 0) + + if len(records) != 1 { + t.Fatalf("应提交 1 条记录,实际 %d", len(records)) + } + r := records[0] + if r.ChannelID != 7 || r.ChannelName != "ch7" || r.ChannelType != int(constant.ProviderKling) { + t.Errorf("渠道元数据映射错误: %+v", r) + } + if r.UpstreamModel != "kling-v2" || r.ModelName != "kling-v2-master" || r.RelayMode != "videos" { + t.Errorf("模型/协议元数据错误: %+v", r) + } + if !r.IsFinal || r.IsStream { + t.Errorf("任务提交应为 is_final=true 且非流式: %+v", r) + } + if string(r.ClientRespBody) != `{"id":"video_x"}` || r.ClientStatusCode != 200 { + t.Errorf("段4 错误: %q %d", r.ClientRespBody, r.ClientStatusCode) + } +} + // 调试目标过滤:AND 组合,0 = 不限 func TestDebugTargetMatch(t *testing.T) { s := ChannelSettings{DebugLogEnabled: true} diff --git a/relay/common/http_client.go b/relay/common/http_client.go index da5091f..15680a3 100644 --- a/relay/common/http_client.go +++ b/relay/common/http_client.go @@ -62,33 +62,29 @@ var ( // proxiedState 管理带代理的 HTTP 客户端,按需初始化并在代理 URL 变更时重建。 var proxiedState struct { - mu sync.RWMutex - proxyURL string - transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) - longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) - nonStream *http.Client - stream *http.Client - proxyURLCached atomic.Value // string - cacheTime atomic.Int64 // 上次读取配置的 unix 时间戳(秒) + mu sync.RWMutex + proxyURL string + transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) + longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) + nonStream *http.Client + stream *http.Client + proxyURLValue atomic.Value // string,系统代理 URL(sys_options channel_proxy_url,由 internal 注入) } -const proxyCacheTTL = 10 // seconds +// SetSystemProxyURL 注入系统代理 URL(sys_options 的 channel_proxy_url)。 +// relay 包不 import internal(会循环依赖),由 internal 侧在启动时与配置变更时 +// (OnSettingsChanged,免重启)注入,模式同 SetGlobalRequestTimeoutSeconds。 +func SetSystemProxyURL(proxyURL string) { + proxiedState.proxyURLValue.Store(proxyURL) +} -// GetSystemProxyURL 从系统配置读取 channel_proxy_url(带本地缓存)。 +// GetSystemProxyURL 返回系统代理 URL(sys_options 的 channel_proxy_url,空串=未配置)。 // 导出供 WebSocket 拨号器等非 HTTP 客户端调用方使用。 func GetSystemProxyURL() string { - now := time.Now().Unix() - last := proxiedState.cacheTime.Load() - if now-last < proxyCacheTTL { - if v, ok := proxiedState.proxyURLCached.Load().(string); ok { - return v - } + if v, ok := proxiedState.proxyURLValue.Load().(string); ok { + return v } - - proxyURL := g.Cfg().MustGet(context.Background(), "channel_proxy_url").String() - proxiedState.proxyURLCached.Store(proxyURL) - proxiedState.cacheTime.Store(now) - return proxyURL + return "" } // buildProxiedTransport 构建一个带(可选)代理的传输层,ResponseHeaderTimeout 由 rht 决定。 diff --git a/relay/common/provider.go b/relay/common/provider.go index ebe243c..846f176 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -170,6 +170,9 @@ type UsageRecord struct { // 其他 token ImageOutputTokens int // 图像输出 token(DALL-E) + // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 0 + DurationSeconds int + // 请求元数据 RequestedModel string // 用户请求的模型名 UpstreamModel string // 上游实际模型名 @@ -182,6 +185,11 @@ type UsageRecord struct { InboundEndpoint string // 客户端请求路径 UpstreamEndpoint string // 上游实际请求路径 + // 上游请求 ID:对话=上游响应头中的请求 ID(ExtractUpstreamRequestID 提取); + // 任务=上游提交响应体中的任务 ID(tsk_model_tasks.private_data 的 upstream_task_id)。 + // 排障时凭此 ID 在上游日志中定位同一次调用;上游未返回时为空。 + UpstreamRequestID string + // 渠道详情 ChannelName string ChannelType int diff --git a/relay/common/reasoning_carry.go b/relay/common/reasoning_carry.go new file mode 100644 index 0000000..34f6160 --- /dev/null +++ b/relay/common/reasoning_carry.go @@ -0,0 +1,79 @@ +package common + +import ( + "sync" + "time" + + "github.com/qianfree/team-api/relaykit/relayconvert/convmeta" +) + +// 思考文本跨轮携带的进程内 TTL 存储(convmeta.ReasoningCarry 的宿主实现)。 +// +// 为什么需要它:DeepSeek 等 thinking 上游要求多轮工具调用把 reasoning_content 传回, +// 而 ai-sdk 系 responses 客户端重建历史时会剥掉 reasoning 项(连 encrypted_content +// 一起丢)。网关在响应侧按 call_id 存本轮思考文本,下一轮请求按 function_call 的 +// call_id 捞回。工具调用 call_id 全局唯一(上游生成),可安全作键。 +// +// 局限:进程内存态。多实例部署时同一会话的两轮请求落在不同实例则携带失效—— +// 客户端若正常回传 reasoning 项(encrypted_content 通道)不依赖本机制。 +const ( + reasoningCarryTTL = 30 * time.Minute + // reasoningCarrySweepAt 超过该条数时顺带清扫过期项(懒清扫,避免常驻 goroutine) + reasoningCarrySweepAt = 4096 +) + +type reasoningCarryEntry struct { + text string + expireAt time.Time +} + +var ( + reasoningCarryMu sync.Mutex + reasoningCarryByCallID = make(map[string]reasoningCarryEntry) +) + +// StoreReasoningForCalls 把本轮思考文本按工具调用 call_id 存入进程内缓存。 +func (info *RelayInfo) StoreReasoningForCalls(callIDs []string, reasoning string) { + if reasoning == "" || len(callIDs) == 0 { + return + } + now := time.Now() + reasoningCarryMu.Lock() + defer reasoningCarryMu.Unlock() + if len(reasoningCarryByCallID) >= reasoningCarrySweepAt { + for k, v := range reasoningCarryByCallID { + if now.After(v.expireAt) { + delete(reasoningCarryByCallID, k) + } + } + } + entry := reasoningCarryEntry{text: reasoning, expireAt: now.Add(reasoningCarryTTL)} + for _, id := range callIDs { + if id != "" { + reasoningCarryByCallID[id] = entry + } + } +} + +// LookupReasoningForCalls 按 call_id 查思考文本,命中任一即返回。 +// 不删除命中项:同一轮请求可能因重试被多次转换,读操作保持幂等,过期由 TTL 收敛。 +func (info *RelayInfo) LookupReasoningForCalls(callIDs []string) string { + now := time.Now() + reasoningCarryMu.Lock() + defer reasoningCarryMu.Unlock() + for _, id := range callIDs { + if id == "" { + continue + } + if entry, ok := reasoningCarryByCallID[id]; ok { + if now.After(entry.expireAt) { + delete(reasoningCarryByCallID, id) + continue + } + return entry.text + } + } + return "" +} + +var _ convmeta.ReasoningCarry = (*RelayInfo)(nil) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 36ba205..c4ca991 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -3,6 +3,7 @@ package common import ( "context" "net/http" + "sync/atomic" "time" "github.com/qianfree/team-api/relay/constant" @@ -38,15 +39,60 @@ func (m *ChannelMeta) UpstreamSpeaksResponses() bool { } const ( - // DefaultTimeoutSeconds 非流式请求的默认总超时(http.Client.Timeout)。 + // DefaultTimeoutSeconds 非流式请求的内置默认总超时(http.Client.Timeout)。 // 推理模型(GLM-4.5/4.6 thinking、Claude thinking 等)非流式调用需跑完整段生成才回响应头, // 60s 会误杀;放宽到 180s。个别仍超时的模型建议在渠道级 settings.timeout_seconds 配 >180(自动切 longRun 传输层)。 + // 该常量仅作系统设置 request_timeout_seconds 未配置时的最终兜底。 DefaultTimeoutSeconds = 180 ImagesGenerationTimeoutSecs = 600 + + // DefaultStreamIdleTimeoutSeconds 流式响应的内置默认空闲超时(两个 chunk 之间的最大间隔,非总时长)。 + // 仅作系统设置 streaming_timeout_seconds 未配置时的最终兜底。 + DefaultStreamIdleTimeoutSeconds = 300 ) +// 全局超时配置(sys_options request_timeout_seconds / streaming_timeout_seconds), +// 0 表示未配置、回落内置默认。relay 包不 import internal(会循环依赖), +// 由 internal 侧在启动时与配置变更时(OnSettingsChanged,免重启)注入。 +var ( + globalRequestTimeoutSeconds atomic.Int64 + globalStreamIdleTimeoutSeconds atomic.Int64 +) + +// SetGlobalRequestTimeoutSeconds 注入全局非流式请求超时(<=0 视为未配置)。 +func SetGlobalRequestTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalRequestTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalRequestTimeoutSeconds 返回全局非流式请求超时(未配置返回内置默认 180s)。 +func GlobalRequestTimeoutSeconds() int { + if v := int(globalRequestTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultTimeoutSeconds +} + +// SetGlobalStreamIdleTimeoutSeconds 注入全局流式空闲超时(<=0 视为未配置)。 +func SetGlobalStreamIdleTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalStreamIdleTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalStreamIdleTimeoutSeconds 返回全局流式空闲超时(未配置返回内置默认 300s)。 +func GlobalStreamIdleTimeoutSeconds() int { + if v := int(globalStreamIdleTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultStreamIdleTimeoutSeconds +} + // GetTimeoutSeconds 返回请求超时秒数。 -// 图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时),其余模式渠道自定义优先。 +// 优先级:渠道自定义 > 全局配置(系统设置)> 内置默认。图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时)。 func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if constant.RelayMode(relayMode) == constant.RelayModeImagesGenerations { if s.TimeoutSeconds > ImagesGenerationTimeoutSecs { @@ -57,7 +103,7 @@ func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if s.TimeoutSeconds > 0 { return s.TimeoutSeconds } - return DefaultTimeoutSeconds + return GlobalRequestTimeoutSeconds() } // ChannelSettings 渠道配置(来自 chn_channels.settings JSONB) @@ -85,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"` @@ -141,6 +191,11 @@ type RelayInfo struct { StreamStatus *StreamStatus FirstResponseTime time.Time + // 上游请求 ID(从上游响应头提取,见 helper.ExtractUpstreamRequestID)。 + // 重试时每次尝试覆盖,终值为最后一次尝试的 ID;用于排障时在上游日志中 + // 定位同一次调用。Realtime(WebSocket)链路无 HTTP 响应头,恒为空。 + UpstreamRequestID string + // 入站格式:openai / claude / gemini / responses // 决定适配器是否需要做格式转换 InboundFormat constant.RelayFormat @@ -161,10 +216,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 @@ -251,20 +303,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 @@ -324,6 +362,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{} @@ -338,19 +394,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/common/task_adaptor.go b/relay/common/task_adaptor.go index 387a869..411708f 100644 --- a/relay/common/task_adaptor.go +++ b/relay/common/task_adaptor.go @@ -15,12 +15,16 @@ type TaskAdaptor interface { // ValidateRequest 校验请求参数 ValidateRequest(ctx context.Context, info *RelayInfo, body []byte) *TaskError - // EstimateBilling 估算任务费用(提交前) - // 返回计费比率 map,如 {"duration_ratio": 1.5, "resolution_ratio": 2.0} - EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]float64 + // EstimateBilling 估算任务费用(提交前)。 + // 返回计费上下文 map,两类键: + // - float64 乘数值:如 {"video_input": 0.6, "quality": 3.5},引擎按序连乘; + // - string 规格事实值:如 {"spec.duration": 8, "spec.resolution": "720p"}, + // per_second 计费模式据此查定价矩阵(spec.duration 为 float64 秒数,spec.resolution 为规格原值)。 + // 旧键 duration/resolution 保留 token 伪装估算路径使用,语义随各 adaptor 存量口径,勿混用。 + EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]any // AdjustBillingOnSubmit 提交后根据上游确认参数调整计费 - AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]float64 + AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]any // BuildRequestURL 构建上游请求 URL BuildRequestURL(info *RelayInfo) (string, error) @@ -37,8 +41,9 @@ type TaskAdaptor interface { // DoResponse 解析上游提交响应,返回上游任务 ID 和任务数据 DoResponse(ctx context.Context, resp *http.Response, info *RelayInfo) (upstreamTaskID string, taskData []byte, taskErr *TaskError) - // FetchTask 查询上游任务状态 - FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) + // FetchTask 查询上游任务状态。ctx 供超时控制与渠道调试日志捕获器传递 + //(轮询侧经 WithDebugAttempt 注入,未开启调试时透传无额外开销) + FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) // ParseTaskResult 解析上游任务查询结果 ParseTaskResult(body []byte) (*TaskInfo, error) @@ -50,6 +55,16 @@ type TaskAdaptor interface { GetChannelName() string } +// UpstreamRequestIDExtractor 供应商私有的「上游调用追踪 ID 提取」可选能力。 +// 为什么需要:部分异步上游(如阿里 DashScope)不在响应头返回请求 ID,而是放在 +// 提交响应体顶层(request_id 字段),通用管线无法用统一键提取。实现方从提交 +// 响应体解析自家私有的追踪 ID,与 output.task_id(任务句柄)互补——提工单或 +// 在上游日志定位一次提交调用时用它。未实现的上游走响应头提取或为空。 +type UpstreamRequestIDExtractor interface { + // ExtractUpstreamRequestID 从上游提交响应体提取调用追踪 ID,未返回时为空串 + ExtractUpstreamRequestID(body []byte) string +} + // TaskInfo 异步任务查询结果 type TaskInfo struct { Status TaskStatusEnum @@ -60,12 +75,28 @@ type TaskInfo struct { SubTasks []SubTask // 子任务结果(如多图/多视频场景) ActualCost float64 // 上游返回的实际费用(0 表示未提供,使用预扣金额) + // 素材计量(部分任务上游 usage 返回,如 MiniMax H3 的 input_seconds/input_image_count): + // 「预扣时未知、生成后官方返回」的计费依据,素材计费方案(BillingScheme.SettleTaskCost)的结算输入。 + // nil = 上游未提供 + MaterialUsage *TaskMaterialUsage + // Token 用量(部分异步任务上游会返回,如火山引擎视频模型) PromptTokens int CompletionTokens int TotalTokens int } +// TaskMaterialUsage 上游任务 usage 中的素材计量。 +// 与 TaskInfo.TotalTokens 同级的结算信号:链接素材(视频/音频)的时长在提交时不可知, +// 以生成成功后官方返回为准;各字段零值表示该计量上游未返回(方案自行回退)。 +type TaskMaterialUsage struct { + InputVideoSeconds float64 // 输入视频时长(秒) + InputImageCount int // 输入图片张数 + InputAudioSeconds float64 // 输入音频时长(秒) + OutputSeconds float64 // 输出(生成)时长(秒) + Resolution string // 实际输出分辨率规格(如 "720P",对齐 per_second 矩阵键);空 = 上游未返回 +} + // TaskStatusEnum 任务状态枚举 type TaskStatusEnum string diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index de7221f..051329d 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -8,10 +8,14 @@ import ( ) // TaskBillingProvider 异步任务计费接口 -// 异步任务计费基于时长/分辨率等比率,非 token 数 +// 异步任务计费基于时长/分辨率等比率,非 token 数。 +// ratios 键约定见 TaskAdaptor.EstimateBilling(float64 乘数 + spec.* 规格事实值) type TaskBillingProvider interface { - // EstimateTaskCost 估算任务费用 - EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error) + // EstimateTaskCost 估算任务费用。 + // taskBody 为归一化后的任务体(转换后通用格式):定价配置了参数倍率(param_multipliers)时, + // 实现方在函数内求值并把命中倍率就地注入 ratios(param_multiplier / param_matched 键, + // 随任务持久化保证预扣/结算/重算三口径同源);nil body = 不求值。 + EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error) // PreDeductTask 预扣任务费用 PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error) @@ -30,7 +34,7 @@ type TaskBillingProvider interface { // SettleTaskSuccess 任务成功结算(含计费快照)。billAt 为任务受理时刻(时段定价按该时刻评估, // 异步任务结算滞后数分钟~小时,按结算时刻会丢失受理时的时段价) - SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*SettlementResult, error) + SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*SettlementResult, error) // SettleTaskFailed 任务失败退还预扣 SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error @@ -43,6 +47,12 @@ type TaskBillingProvider interface { // RecalculateByTokens 根据上游返回的 total_tokens 重算费用(billAt 为任务受理时刻,时段定价用) // totalTokens: 上游返回的 token 计费单位 - // ratios: 提交时保存的计费比率(如 video_input 折扣) - RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error) + // ratios: 提交时保存的计费上下文(如 video_input 折扣) + RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error) + + // RecalculateByMaterials 按上游 usage 的素材计量重算任务费用(素材计费方案的结算依据)。 + // 仅模型配置了素材计费方案(pricing JSONB scheme)时适用:ok=true 返回按方案结算口径 + // 重算的最终费用;未配置方案、取价失败或 usage 为 nil 时 ok=false,调用方保持既有结算来源 + // (预扣金额 / token 重算)。billAt 为任务受理时刻(时段定价用),ratios 为提交时持久化的计费上下文 + RecalculateByMaterials(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, usage *TaskMaterialUsage, billAt time.Time) (cost decimal.Decimal, ok bool, err error) } diff --git a/relay/common/task_provider.go b/relay/common/task_provider.go index 29c7770..405277f 100644 --- a/relay/common/task_provider.go +++ b/relay/common/task_provider.go @@ -25,6 +25,10 @@ type TaskDataProvider interface { // GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询(客户端查询) GetTaskByPublicIDAndUser(ctx context.Context, publicTaskID string, userID int64, tenantID int64) (*AsyncTask, error) + // SoftDeleteTask 软删除任务(DELETE /v1/videos/{id}):置 deleted 标记, + // 计费与审计记录保留,后续客户端查询按不存在处理 + SoftDeleteTask(ctx context.Context, task *AsyncTask) error + // GetNonTerminalTasks 获取所有非终态任务(轮询用) GetNonTerminalTasks(ctx context.Context, limit int) ([]*AsyncTask, error) @@ -74,6 +78,10 @@ type AsyncTask struct { FinishTime *time.Time `json:"finish_time,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // 软删除标记(DELETE /v1/videos/{id}):true 后客户端查询/下载按 404 处理 + Deleted bool `json:"deleted,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // ChannelBasicInfo 渠道基本信息(轮询时查询) diff --git a/relay/constant/errors.go b/relay/constant/errors.go index 8fc237b..a02e2d2 100644 --- a/relay/constant/errors.go +++ b/relay/constant/errors.go @@ -15,7 +15,7 @@ import ( ) // Relay 层哨兵错误 -var ErrAllChannelsFailed = gerror.New("all channels failed") +var ErrAllChannelsFailed = gerror.New("no available channels") // ErrStatefulResponsesUnsupported responses 有状态请求(previous_response_id)落在 // 不支持 Responses 协议的 chat-only 渠道上,降级转换会静默丢失会话上下文,必须快速失败。 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/constant/relay_mode.go b/relay/constant/relay_mode.go index 46e1ee4..bf60f43 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -31,6 +31,9 @@ const ( RelayModeMjFetch // GET /mj/task/:id/fetch RelayModeMjImage // GET /mj/image/:id RelayModeResponsesCompact // POST /v1/responses/compact + RelayModeVideos // OpenAI Videos 协议 /v1/videos(创建/查询/删除/内容下载) + RelayModeMiniMaxVideo // MiniMax 官方协议 /v2/video_generation(H3 v2 提交/查询/删除) + RelayModeAliVideo // 阿里 DashScope 官方协议 /api/v1/services/aigc/video-generation/video-synthesis(提交/查询) ) // Path2RelayMode 根据请求路径判断 RelayMode @@ -101,6 +104,8 @@ func Path2RelayMode(path string) RelayMode { result = RelayModeVideoGenerations case strings.Contains(path, "/video/generations/"): result = RelayModeVideoFetch + case strings.HasSuffix(path, "/videos"), strings.Contains(path, "/videos/"): + result = RelayModeVideos case strings.HasSuffix(path, "/moderations"): result = RelayModeModerations default: @@ -151,6 +156,12 @@ func (m RelayMode) String() string { return "moderations" case RelayModeImagesEdits: return "images_edits" + case RelayModeVideos: + return "videos" + case RelayModeMiniMaxVideo: + return "minimax_video" + case RelayModeAliVideo: + return "ali_video" case RelayModeMjSubmit: return "mj_submit" case RelayModeMjFetch: diff --git a/relay/constant/task.go b/relay/constant/task.go index ebea0cf..afbf577 100644 --- a/relay/constant/task.go +++ b/relay/constant/task.go @@ -13,6 +13,7 @@ const ( TaskPlatformVolcengine TaskPlatform = "volcengine" TaskPlatformAli TaskPlatform = "ali" TaskPlatformGemini TaskPlatform = "gemini" + TaskPlatformMiniMax TaskPlatform = "minimax" // TaskPlatformSyncImage 同步图片厂商异步化专用平台标记。 // 注意:故意不加入 ProviderTypeToTaskPlatform —— 它由 sync_image worker 池执行, @@ -94,6 +95,8 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) { return TaskPlatformAli, true case ProviderGemini: return TaskPlatformGemini, true + case ProviderMiniMax: + return TaskPlatformMiniMax, true default: return "", false } 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/ali_video_convert.go b/relay/handler/ali_video_convert.go new file mode 100644 index 0000000..ae78a8d --- /dev/null +++ b/relay/handler/ali_video_convert.go @@ -0,0 +1,263 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 阿里 DashScope 官方视频协议(POST /api/v1/services/aigc/video-generation/video-synthesis)入站转换层。 +// 原生请求体(model/input.prompt/input.media[]/parameters)解析校验后转成任务框架通用请求体, +// 复用任务提交管线(selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库); +// 查询端点 GET /api/v1/tasks/{task_id} 回放 DashScope 官方响应形态(协议文档 +// docs/协议文档/阿里系列/视频-wan3.0.md),阿里 dashscope SDK 换 base_url 即可直连。 +// +// 转换产物在保留原生字段(media[]/negative_prompt/audio_url)之外,双写通用词汇 +// (prompt/seconds/metadata)——参数倍率规则的匹配源是归一化任务体(与 MiniMax 官方 +// 协议入站同一约定),保证按通用路径配置的倍率规则对官方协议入站同样生效。 + +// aliVideoProtocol TaskRelayContext.Protocol 的阿里 DashScope 官方视频协议标识 +const aliVideoProtocol = "ali_native" + +// aliVideoCreateRequest DashScope 官方创建请求的归一形态(JSON 解析后的产物) +type aliVideoCreateRequest struct { + Model string + Prompt string + NegativePrompt string + AudioURL string + Media []aliVideoMedia + Parameters aliVideoParameters +} + +// aliVideoMedia wan3.0 input.media 数组元素 +type aliVideoMedia struct { + Type string `json:"type"` + URL string `json:"url"` +} + +// aliVideoParameters DashScope parameters(wan2.x 与 wan3.0 字段并集) +type aliVideoParameters struct { + Resolution string `json:"resolution,omitempty"` + Ratio string `json:"ratio,omitempty"` + Size string `json:"size,omitempty"` + Duration *int `json:"duration,omitempty"` // wan3.0:-1 = 智能时长 + Audio *bool `json:"audio,omitempty"` // wan3.0:输出音频开关 + Seed *int `json:"seed,omitempty"` + PromptExtend *bool `json:"prompt_extend,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} + +// jsonAliVideoInput 官方请求的 input 节(wan2.x 扁平字段 + wan3.0 media 数组并集) +type jsonAliVideoInput struct { + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt"` + AudioURL string `json:"audio_url"` + Media []aliVideoMedia `json:"media"` +} + +// ParseAliVideoCreateRequest 解析官方创建请求。 +// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。 +func ParseAliVideoCreateRequest(body []byte) (*aliVideoCreateRequest, *common.TaskError) { + var raw struct { + Model string `json:"model"` + Input jsonAliVideoInput `json:"input"` + Parameters aliVideoParameters `json:"parameters"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + return &aliVideoCreateRequest{ + Model: strings.TrimSpace(raw.Model), + Prompt: raw.Input.Prompt, + NegativePrompt: raw.Input.NegativePrompt, + AudioURL: raw.Input.AudioURL, + Media: raw.Input.Media, + Parameters: raw.Parameters, + }, nil +} + +// ValidateAliVideoCreateRequest 请求必填校验(对齐官方约束:model 必填,prompt 与 media 必填其一) +func ValidateAliVideoCreateRequest(req *aliVideoCreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if strings.TrimSpace(req.Prompt) == "" && len(req.Media) == 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of input.prompt / input.media is required"} + } + return nil +} + +// BuildAliVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。 +// media 数组放顶层原样透传(适配器 buildWan3Media 消费);通用词汇(seconds/metadata) +// 双写保证参数倍率规则与 per_second 预扣信号(spec.duration/spec.resolution)同源。 +func BuildAliVideoTaskBody(req *aliVideoCreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + "prompt": req.Prompt, + } + if len(req.Media) > 0 { + body["media"] = req.Media + } + + metadata := map[string]any{} + if req.NegativePrompt != "" { + body["negative_prompt"] = req.NegativePrompt + metadata["negative_prompt"] = req.NegativePrompt + } + if req.AudioURL != "" { + body["audio_url"] = req.AudioURL + metadata["audio_url"] = req.AudioURL + } + + p := req.Parameters + if p.Resolution != "" { + body["resolution"] = p.Resolution + metadata["resolution"] = p.Resolution + } + if p.Ratio != "" { + body["ratio"] = p.Ratio + metadata["ratio"] = p.Ratio + } + if p.Size != "" { + metadata["size"] = p.Size + } + // duration 保留原值(含 -1 智能时长信号,供适配器预扣按上限冻结);正数另写通用 seconds + if p.Duration != nil { + metadata["duration"] = *p.Duration + if *p.Duration > 0 { + body["seconds"] = strconv.Itoa(*p.Duration) + } + } + if p.Audio != nil { + metadata["sound"] = map[bool]string{true: "on", false: "off"}[*p.Audio] + metadata["generate_audio"] = *p.Audio + } + if p.Seed != nil { + metadata["seed"] = *p.Seed + } + if p.PromptExtend != nil { + metadata["prompt_extend"] = *p.PromptExtend + } + if p.Watermark != nil { + metadata["watermark"] = *p.Watermark + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal ali video task body: %w", err) + } + return data, nil +} + +// ==================== 查询端点回放 ==================== + +// aliTaskResponse DashScope 任务查询响应形态(GET /api/v1/tasks/{task_id} 的回放产物)。 +// usage 用 RawMessage 原样保留上游计量(duration/output_video_duration/fps/SR/ratio) +type aliTaskResponse struct { + Output struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + SubmitTime string `json:"submit_time,omitempty"` + ScheduledTime string `json:"scheduled_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + OrigPrompt string `json:"orig_prompt,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + } `json:"output"` + Usage json.RawMessage `json:"usage,omitempty"` + RequestID string `json:"request_id"` +} + +// aliStatusFromTask 平台任务状态 → DashScope 官方状态机映射 +func aliStatusFromTask(status string) string { + switch status { + case "IN_PROGRESS": + return "RUNNING" + case "SUCCESS": + return "SUCCEEDED" + case "FAILURE": + return "FAILED" + default: + // NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 PENDING + return "PENDING" + } +} + +// buildAliTaskResponse 平台任务 → DashScope 官方查询响应。 +// 优先回放 task.Data 缓存的上游查询响应(含 usage/orig_prompt/时间线,保真度最高), +// 仅覆写 output.task_id 为平台公开任务 ID、task_status 以平台状态为准(上游响应原文 +// 可能滞后一轮轮询);task.Data 不可解析(提交后首拍轮询前为提交响应形态或空)时 +// 构造最小响应。失败任务补 code/message(上游已带则保留) +func buildAliTaskResponse(task *common.AsyncTask) *aliTaskResponse { + resp := &aliTaskResponse{} + if len(task.Data) > 0 { + _ = json.Unmarshal(task.Data, resp) + } + status := aliStatusFromTask(task.Status) + + resp.Output.TaskID = task.PublicTaskID + resp.Output.TaskStatus = status + resp.RequestID = task.RequestID + + if status == "FAILED" { + if resp.Output.Code == "" { + resp.Output.Code = "TaskFailed" + } + if resp.Output.Message == "" { + resp.Output.Message = task.FailReason + if resp.Output.Message == "" { + resp.Output.Message = "video generation failed" + } + } + } + return resp +} + +// HandleAliVideoRetrieve GET /api/v1/tasks/{task_id} 查询管线(回放 DashScope 响应形态)。 +// 租户双键隔离(user_id + tenant_id)复用 GetTaskByPublicIDAndUser;平台门禁:该端点只服务 +// ali 平台任务,防止异构平台任务 ID 被塞进 DashScope 响应结构回放(对齐 MiniMax 官方端点做法) +func HandleAliVideoRetrieve( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "AliVideoRetrieve: query task failed, taskID=%s, err=%v", taskID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil || task.Platform != string(constant.TaskPlatformAli) { + writeTaskError(rc.Writer, http.StatusNotFound, "task not found", "") + return + } + writeJSON(rc.Writer, http.StatusOK, buildAliTaskResponse(task)) +} + +// writeAliVideoSubmitResponse 提交成功响应:官方 DashScope 形态 +// ({"output":{"task_status":"PENDING","task_id":...},"request_id":...}) +func writeAliVideoSubmitResponse(w http.ResponseWriter, publicTaskID, requestID string, now time.Time) { + _ = now // 提交响应形态无时间字段,参数保留对齐其他协议 writer 的签名习惯 + writeJSON(w, http.StatusOK, map[string]any{ + "output": map[string]any{ + "task_status": "PENDING", + "task_id": publicTaskID, + }, + "request_id": requestID, + }) +} diff --git a/relay/handler/ali_video_convert_test.go b/relay/handler/ali_video_convert_test.go new file mode 100644 index 0000000..3355457 --- /dev/null +++ b/relay/handler/ali_video_convert_test.go @@ -0,0 +1,205 @@ +package handler + +import ( + "encoding/json" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" +) + +func TestParseAliVideoCreateRequest_Full(t *testing.T) { + body := `{ + "model": "wan3.0-video", + "input": { + "prompt": "一只小猫在月光下的屋顶上奔跑", + "media": [ + {"type": "first_frame", "url": "https://example.com/f.png"}, + {"type": "reference_image", "url": "https://example.com/r.png"} + ] + }, + "parameters": { + "resolution": "1080P", "ratio": "16:9", "duration": 5, + "audio": false, "seed": 42, "prompt_extend": true, "watermark": false + } + }` + req, taskErr := ParseAliVideoCreateRequest([]byte(body)) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "wan3.0-video" || req.Prompt != "一只小猫在月光下的屋顶上奔跑" { + t.Fatalf("unexpected request: %+v", req) + } + if len(req.Media) != 2 || req.Media[0].Type != "first_frame" || req.Media[1].URL != "https://example.com/r.png" { + t.Fatalf("unexpected media: %+v", req.Media) + } + if req.Parameters.Resolution != "1080P" || req.Parameters.Ratio != "16:9" { + t.Fatalf("unexpected parameters: %+v", req.Parameters) + } + if req.Parameters.Duration == nil || *req.Parameters.Duration != 5 { + t.Fatalf("unexpected duration: %+v", req.Parameters.Duration) + } + if req.Parameters.Audio == nil || *req.Parameters.Audio { + t.Fatalf("unexpected audio: %+v", req.Parameters.Audio) + } +} + +func TestParseAliVideoCreateRequest_LegacyWan2(t *testing.T) { + // wan2.x 旧格式(扁平 input,无 media)同样可解析 + body := `{"model":"wan2.2-t2v-plus","input":{"prompt":"p","negative_prompt":"np","audio_url":"https://a.mp3"},"parameters":{"size":"1024*576"}}` + req, taskErr := ParseAliVideoCreateRequest([]byte(body)) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "wan2.2-t2v-plus" || req.Prompt != "p" || req.NegativePrompt != "np" || req.AudioURL != "https://a.mp3" { + t.Fatalf("unexpected request: %+v", req) + } + if len(req.Media) != 0 { + t.Fatalf("wan2 request must not carry media: %+v", req.Media) + } +} + +func TestParseAliVideoCreateRequest_InvalidJSON(t *testing.T) { + if _, taskErr := ParseAliVideoCreateRequest([]byte(`{invalid`)); taskErr == nil || taskErr.StatusCode != 400 { + t.Fatalf("expected 400 task error, got %+v", taskErr) + } +} + +func TestValidateAliVideoCreateRequest(t *testing.T) { + // 缺 model + if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Prompt: "p"}); taskErr == nil { + t.Error("missing model should be rejected") + } + // prompt 与 media 都缺(文档约束:必填其一) + if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{Model: "wan3.0-video"}); taskErr == nil { + t.Error("missing both prompt and media should be rejected") + } + // 纯 media(无 prompt)合法 + if taskErr := ValidateAliVideoCreateRequest(&aliVideoCreateRequest{ + Model: "wan3.0-video", + Media: []aliVideoMedia{{Type: "reference_video", URL: "https://v.mp4"}}, + }); taskErr != nil { + t.Errorf("media-only request should be accepted: %+v", taskErr) + } +} + +func TestBuildAliVideoTaskBody(t *testing.T) { + duration := 5 + audio := false + body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{ + Model: "wan3.0-video", + Prompt: "p", + Media: []aliVideoMedia{{Type: "first_frame", URL: "https://f.png"}}, + Parameters: aliVideoParameters{ + Resolution: "480P", Ratio: "16:9", Duration: &duration, Audio: &audio, + }, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // 顶层保留:model/prompt/media/通用 seconds + if m["model"] != "wan3.0-video" || m["prompt"] != "p" || m["seconds"] != "5" { + t.Fatalf("unexpected top-level body: %s", body) + } + media, ok := m["media"].([]any) + if !ok || len(media) != 1 { + t.Fatalf("expected media passthrough: %s", body) + } + // metadata 双写:duration/resolution/ratio/sound/generate_audio + meta, _ := m["metadata"].(map[string]any) + if meta["duration"] != float64(5) || meta["resolution"] != "480P" || meta["ratio"] != "16:9" { + t.Fatalf("unexpected metadata: %v", meta) + } + if meta["sound"] != "off" || meta["generate_audio"] != false { + t.Fatalf("audio switch not normalized: %v", meta) + } +} + +func TestBuildAliVideoTaskBody_SmartDuration(t *testing.T) { + // duration=-1(智能时长):metadata 保留 -1 信号(适配器预扣按 30s 上限冻结),不写 seconds + minusOne := -1 + body, err := BuildAliVideoTaskBody(&aliVideoCreateRequest{ + Model: "wan3.0-video", + Prompt: "p", + Parameters: aliVideoParameters{Duration: &minusOne}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + _ = json.Unmarshal(body, &m) + if _, has := m["seconds"]; has { + t.Fatalf("smart duration must not emit seconds: %s", body) + } + meta, _ := m["metadata"].(map[string]any) + if meta["duration"] != float64(-1) { + t.Fatalf("smart duration signal lost: %v", meta) + } +} + +func TestBuildAliTaskResponse(t *testing.T) { + now := time.Now() + submitted := now.Add(-time.Minute) + + // 终态成功:回放上游查询响应(usage/orig_prompt 保真),覆写 task_id/status + task := &common.AsyncTask{ + PublicTaskID: "task_pub_1", + RequestID: "req_1", + Platform: "ali", + Status: "SUCCESS", + CreatedAt: submitted, + Data: []byte(`{"output":{"task_id":"up-stream-id","task_status":"RUNNING","video_url":"https://v.mp4","orig_prompt":"a cat"},"usage":{"video_count":1,"duration":5.0,"output_video_duration":5.13,"fps":30,"SR":720,"ratio":"16:9"},"request_id":"up-req"}`), + } + resp := buildAliTaskResponse(task) + if resp.Output.TaskID != "task_pub_1" || resp.Output.TaskStatus != "SUCCEEDED" { + t.Fatalf("unexpected replay: %+v", resp.Output) + } + if resp.Output.VideoURL != "https://v.mp4" || resp.Output.OrigPrompt != "a cat" { + t.Fatalf("upstream fields must be preserved: %+v", resp.Output) + } + var usage map[string]any + if err := json.Unmarshal(resp.Usage, &usage); err != nil { + t.Fatalf("usage not preserved: %v", err) + } + if usage["output_video_duration"] != 5.13 || usage["SR"] != float64(720) { + t.Fatalf("usage metering lost: %v", usage) + } + if resp.RequestID != "req_1" { + t.Fatalf("request id should be platform's: %s", resp.RequestID) + } + + // 非终态(提交后首拍,Data 为提交响应形态):构造 PENDING + pending := &common.AsyncTask{ + PublicTaskID: "task_pub_2", + RequestID: "req_2", + Platform: "ali", + Status: "SUBMITTED", + Data: []byte(`{"output":{"task_status":"PENDING","task_id":"up-2"},"request_id":"up-req-2"}`), + } + resp = buildAliTaskResponse(pending) + if resp.Output.TaskID != "task_pub_2" || resp.Output.TaskStatus != "PENDING" { + t.Fatalf("unexpected pending replay: %+v", resp.Output) + } + + // Data 为空:构造最小响应 + empty := &common.AsyncTask{PublicTaskID: "task_pub_3", RequestID: "req_3", Platform: "ali", Status: "IN_PROGRESS"} + resp = buildAliTaskResponse(empty) + if resp.Output.TaskID != "task_pub_3" || resp.Output.TaskStatus != "RUNNING" { + t.Fatalf("unexpected empty-data replay: %+v", resp.Output) + } + + // 失败任务:上游 code/message 保留;缺失时用平台 FailReason 兜底 + failed := &common.AsyncTask{ + PublicTaskID: "task_pub_4", RequestID: "req_4", Platform: "ali", Status: "FAILURE", + FailReason: "upstream quota exceeded", + Data: []byte(`{"output":{"task_id":"up-4","task_status":"FAILED","code":"Throttling"}}`), + } + resp = buildAliTaskResponse(failed) + if resp.Output.Code != "Throttling" || resp.Output.Message != "upstream quota exceeded" { + t.Fatalf("unexpected failure replay: %+v", resp.Output) + } +} diff --git a/relay/handler/ali_video_pipeline_test.go b/relay/handler/ali_video_pipeline_test.go new file mode 100644 index 0000000..34d7ef0 --- /dev/null +++ b/relay/handler/ali_video_pipeline_test.go @@ -0,0 +1,208 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 端到端集成测试:阿里 DashScope 官方协议 / OpenAI Videos 协议 → 转换 → 任务提交管线 +// (ali 适配器 + mock 上游)→ task_ 前缀 ID + DashScope 官方响应形态 + 落库 platform=ali +// + 上游收到 media 数组与映射参数。 + +// newAliUpstream 构造 mock 阿里 DashScope 上游(校验端点/异步头,记录请求体) +func newAliUpstream(t *testing.T, received *map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/v1/services/aigc/video-generation/video-synthesis") { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + if r.Header.Get("X-DashScope-Async") != "enable" { + t.Errorf("missing X-DashScope-Async header: %v", r.Header) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, received); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "output": map[string]any{"task_status": "PENDING", "task_id": "ali-up-1"}, + "request_id": "up-req-1", + }) + })) +} + +func newAliChannelMeta(upstreamURL string) *common.ChannelMeta { + return &common.ChannelMeta{ + ChannelID: 21, + ChannelType: int(constant.ProviderAli), + ChannelName: "mock-ali", + BaseURL: upstreamURL, + ApiKey: "sk-test", + UpstreamModelName: "wan3.0-video", + } +} + +func TestHandleTaskSubmit_AliNativeProtocol_FullPipeline(t *testing.T) { + var upstreamReceived map[string]any + upstream := newAliUpstream(t, &upstreamReceived) + defer upstream.Close() + + // 官方请求体 → 归一 → 通用任务体(与胶水层 HandleAliVideoSubmit 相同链路) + raw := `{ + "model": "wan3.0-video", + "input": { + "prompt": "小猫奔跑", + "media": [{"type": "first_frame", "url": "https://example.com/f.png"}] + }, + "parameters": {"resolution": "480P", "ratio": "16:9", "duration": 5, "audio": false} + }` + req, taskErr := ParseAliVideoCreateRequest([]byte(raw)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildAliVideoTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_ali_test", + Writer: rec, + Protocol: aliVideoProtocol, + RelayMode: int(constant.RelayModeAliVideo), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis", + http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL)) + + // 客户端响应:DashScope 官方形态 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + output, _ := resp["output"].(map[string]any) + if output == nil || output["task_status"] != "PENDING" { + t.Fatalf("unexpected dashscope output: %s", rec.Body.String()) + } + taskID, _ := output["task_id"].(string) + if !strings.HasPrefix(taskID, "task_") { + t.Fatalf("expected task_ prefixed id, got %q", taskID) + } + if resp["request_id"] != "req_ali_test" { + t.Fatalf("expected platform request id: %s", rec.Body.String()) + } + if rc.TaskID != taskID { + t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID) + } + + // 落库任务:ali 平台 + 上游任务 ID + if data.created == nil || data.created.Platform != "ali" { + t.Fatalf("expected ali task record, got %+v", data.created) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "ali-up-1" { + t.Fatalf("expected upstream task id captured, got %q", pd.UpstreamTaskID) + } + + // 上游收到:media 数组透传 + parameters 映射(resolution/ratio/duration/audio) + input, _ := upstreamReceived["input"].(map[string]any) + if input == nil || input["prompt"] != "小猫奔跑" { + t.Fatalf("upstream input: %v", upstreamReceived["input"]) + } + media, _ := input["media"].([]any) + if len(media) != 1 { + t.Fatalf("expected media array forwarded, got %v", input["media"]) + } + first, _ := media[0].(map[string]any) + if first["type"] != "first_frame" || first["url"] != "https://example.com/f.png" { + t.Fatalf("unexpected media item: %v", media[0]) + } + params, _ := upstreamReceived["parameters"].(map[string]any) + if params == nil || params["resolution"] != "480P" || params["ratio"] != "16:9" || + params["duration"] != float64(5) || params["audio"] != false { + t.Fatalf("upstream parameters: %v", upstreamReceived["parameters"]) + } +} + +func TestHandleTaskSubmit_OpenAIVideosToWan3_Pipeline(t *testing.T) { + // OpenAI Videos 协议(multipart)→ BuildVideosTaskBody → ali 适配器: + // input_reference → media[{type:first_frame}]、seconds → duration、size 短边 → resolution、 + // sound → audio + var upstreamReceived map[string]any + upstream := newAliUpstream(t, &upstreamReceived) + defer upstream.Close() + + mBody := []byte(`{"model":"wan3.0-video","prompt":"小猫对着镜头打哈欠","seconds":"8","size":"1280x720","aspect_ratio":"9:16","sound":"on","input_reference":{"image_url":"https://example.com/ref.png"}}`) + req, taskErr := ParseVideosCreateRequest(mBody, "application/json") + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_ali_openai_test", + Writer: rec, + Protocol: aliVideoProtocol, + RelayMode: int(constant.RelayModeAliVideo), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/api/v1/services/aigc/video-generation/video-synthesis", + http.Header{}, rc, data, &stubVideosBillingProvider{}, newAliChannelMeta(upstream.URL)) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if data.created == nil || data.created.Platform != "ali" { + t.Fatalf("expected ali task record, got %+v", data.created) + } + + // 上游收到映射后的 wan3.0 参数 + input, _ := upstreamReceived["input"].(map[string]any) + media, _ := input["media"].([]any) + if len(media) != 1 { + t.Fatalf("expected first_frame media from input_reference, got %v", input["media"]) + } + first, _ := media[0].(map[string]any) + if first["type"] != "first_frame" { + t.Fatalf("input_reference should map to first_frame: %v", media[0]) + } + params, _ := upstreamReceived["parameters"].(map[string]any) + if params == nil || params["duration"] != float64(8) { + t.Fatalf("seconds → duration mapping lost: %v", upstreamReceived["parameters"]) + } + if params["resolution"] != "720P" { + t.Fatalf("size short-edge → resolution mapping lost: %v", upstreamReceived["parameters"]) + } + if params["ratio"] != "9:16" { + t.Fatalf("aspect_ratio → ratio mapping lost: %v", upstreamReceived["parameters"]) + } + if params["audio"] != true { + t.Fatalf("sound → audio mapping lost: %v", upstreamReceived["parameters"]) + } +} 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/count_tokens.go b/relay/handler/count_tokens.go new file mode 100644 index 0000000..5c40387 --- /dev/null +++ b/relay/handler/count_tokens.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "errors" + "math" + "strings" + + "github.com/qianfree/team-api/relay/dto" +) + +// count_tokens 端点的本地估算参数。 +// +// 说明:本平台不内置精确分词器(tiktoken 等),POST /v1/messages/count_tokens +// 采用「本地粗略估算」策略——不请求上游、不计费,仅供客户端做上下文窗口管理的近似参考, +// 返回值不保证与上游精确计数一致。估算按字符类别加权:CJK 表意文字 token 密度远高于拉丁字母。 +const ( + latinTokenWeight = 0.25 // 拉丁字母/数字/空白/标点:约 4 字符/token + cjkTokenWeight = 0.6 // CJK 表意文字:约 1.67 字符/token + + claudeImageTokens = 1600 // 单张图片/文档的粗略 token 估值(无法本地解码,按固定值计) + claudeMessageOverhead = 3 // 每条消息的结构化开销 + claudeToolOverhead = 8 // 每个工具定义的结构化开销 + claudeRequestOverhead = 8 // 请求整体固定开销(system 包装、消息分隔等) +) + +// errCountTokensInvalid 表示 count_tokens 请求体校验失败,调用方统一映射为 400 invalid_request_error。 +var errCountTokensInvalid = errors.New("invalid count_tokens request") + +// ctRequest count_tokens 的宽松解析结构:content / system / input_schema 保留为 RawMessage, +// 便于区分「字符串」与「内容块数组」两种形态后分别估算。 +type ctRequest struct { + Model string `json:"model"` + System json.RawMessage `json:"system"` + Messages []ctMessage `json:"messages"` + Tools []ctTool `json:"tools"` +} + +type ctMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +type ctTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` +} + +// CountClaudeTokens 本地估算 Claude Messages 请求的输入 token 数。 +// 返回 (input_tokens, error);error 非 nil 时均为请求体校验错误。 +func CountClaudeTokens(body []byte) (int, error) { + var req ctRequest + if err := json.Unmarshal(body, &req); err != nil { + return 0, errWithMessage("invalid request body") + } + if strings.TrimSpace(req.Model) == "" { + return 0, errWithMessage("model is required") + } + if len(req.Messages) == 0 { + return 0, errWithMessage("messages is required") + } + + tokens := claudeRequestOverhead + + // system:string 或 []ClaudeContentBlock + tokens += estimateContentTokens(req.System) + + // messages + for _, msg := range req.Messages { + tokens += estimateContentTokens(msg.Content) + tokens += claudeMessageOverhead + } + + // tools:名称 + 描述 + input_schema(JSON 全文计入) + for _, tool := range req.Tools { + tokens += estimateTextTokens(tool.Name) + tokens += estimateTextTokens(tool.Description) + tokens += estimateRawTokens(tool.InputSchema) + tokens += claudeToolOverhead + } + + return tokens, nil +} + +// estimateContentTokens 估算 message.content / system 的 token 数,兼容字符串与内容块数组两种形态。 +func estimateContentTokens(raw json.RawMessage) int { + trimmed := bytesTrimSpace(raw) + if len(trimmed) == 0 || string(trimmed) == "null" { + return 0 + } + + // 形态一:纯字符串 + var s string + if json.Unmarshal(trimmed, &s) == nil { + return estimateTextTokens(s) + } + + // 形态二:内容块数组 + var blocks []dto.ClaudeContentBlock + if json.Unmarshal(trimmed, &blocks) == nil { + total := 0 + for i := range blocks { + total += estimateBlockTokens(&blocks[i]) + } + return total + } + + // 兜底:无法结构化解析时,按原始 JSON 文本估算,避免漏计 + return estimateRawTokens(trimmed) +} + +// estimateBlockTokens 估算单个内容块的 token 数(text / thinking / tool_use / tool_result / image 等)。 +func estimateBlockTokens(b *dto.ClaudeContentBlock) int { + total := 0 + if b.Text != nil { + total += estimateTextTokens(*b.Text) + } + if b.Thinking != nil { + total += estimateTextTokens(*b.Thinking) + } + if b.Name != "" { + total += estimateTextTokens(b.Name) + } + if b.Input != nil { + total += estimateAnyTokens(b.Input) // tool_use 的入参 + } + if b.Content != nil { + total += estimateAnyContent(b.Content) // tool_result 的嵌套内容(string 或 []block) + } + if b.Source != nil { + total += claudeImageTokens // 图片/文档:固定估值 + } + return total +} + +// estimateAnyContent 估算 tool_result.content:可能是字符串,也可能是内容块数组。 +func estimateAnyContent(v any) int { + if v == nil { + return 0 + } + if s, ok := v.(string); ok { + return estimateTextTokens(s) + } + raw, err := json.Marshal(v) + if err != nil { + return 0 + } + return estimateContentTokens(raw) +} + +// estimateAnyTokens 将任意结构(tool_use 入参等)序列化为 JSON 后按文本估算。 +func estimateAnyTokens(v any) int { + if v == nil { + return 0 + } + raw, err := json.Marshal(v) + if err != nil { + return 0 + } + return estimateTextTokens(string(raw)) +} + +// estimateRawTokens 按原始 JSON 字节的文本估算(input_schema 等已是 JSON 的场景)。 +func estimateRawTokens(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + return estimateTextTokens(string(raw)) +} + +// estimateTextTokens 按字符类别粗略估算文本 token 数:CJK 表意文字权重高,其余字符权重低。 +func estimateTextTokens(text string) int { + if text == "" { + return 0 + } + var weighted float64 + for _, r := range text { + if isCJK(r) { + weighted += cjkTokenWeight + } else { + weighted += latinTokenWeight + } + } + return int(math.Ceil(weighted)) +} + +// isCJK 判断字符是否属于常见 CJK 表意文字区段(中日韩统一表意文字及扩展、假名、谚文等)。 +func isCJK(r rune) bool { + switch { + case r >= 0x4E00 && r <= 0x9FFF, // CJK 统一表意文字 + r >= 0x3400 && r <= 0x4DBF, // CJK 扩展 A + r >= 0x20000 && r <= 0x2A6DF, // CJK 扩展 B + r >= 0xF900 && r <= 0xFAFF, // CJK 兼容表意文字 + r >= 0x3040 && r <= 0x30FF, // 平假名 + 片假名 + r >= 0xAC00 && r <= 0xD7AF, // 谚文音节 + r >= 0x3000 && r <= 0x303F: // CJK 符号和标点 + return true + } + return false +} + +// bytesTrimSpace 去除 RawMessage 两端的 JSON 空白字符。 +func bytesTrimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +// errWithMessage 构造 count_tokens 校验错误,消息文本即返回给客户端的错误信息。 +func errWithMessage(msg string) error { + return &countTokensError{message: msg} +} + +// countTokensError 携带面向客户端的错误信息,供 handler 以 Claude 原生格式回写。 +type countTokensError struct { + message string +} + +func (e *countTokensError) Error() string { return e.message } +func (e *countTokensError) Is(target error) bool { + return target == errCountTokensInvalid +} diff --git a/relay/handler/count_tokens_test.go b/relay/handler/count_tokens_test.go new file mode 100644 index 0000000..81c9db5 --- /dev/null +++ b/relay/handler/count_tokens_test.go @@ -0,0 +1,91 @@ +package handler + +import ( + "errors" + "testing" +) + +func TestCountClaudeTokens_Basic(t *testing.T) { + body := []byte(`{ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "count this prompt please"}], + "tools": [{"name": "lookup", "description": "Look up a value", "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}}] + }`) + tokens, err := CountClaudeTokens(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens <= 0 { + t.Fatalf("expected positive input_tokens, got %d", tokens) + } +} + +func TestCountClaudeTokens_MissingMessages(t *testing.T) { + body := []byte(`{"model": "claude-3-5-sonnet"}`) + _, err := CountClaudeTokens(body) + if err == nil { + t.Fatal("expected error for missing messages") + } + if !errors.Is(err, errCountTokensInvalid) { + t.Fatalf("expected errCountTokensInvalid, got %v", err) + } + if err.Error() != "messages is required" { + t.Fatalf("unexpected message: %q", err.Error()) + } +} + +func TestCountClaudeTokens_MissingModel(t *testing.T) { + body := []byte(`{"messages": [{"role": "user", "content": "hi"}]}`) + _, err := CountClaudeTokens(body) + if err == nil || err.Error() != "model is required" { + t.Fatalf("expected model required error, got %v", err) + } +} + +func TestCountClaudeTokens_InvalidBody(t *testing.T) { + _, err := CountClaudeTokens([]byte(`not json`)) + if err == nil || !errors.Is(err, errCountTokensInvalid) { + t.Fatalf("expected invalid request error, got %v", err) + } +} + +func TestCountClaudeTokens_ContentBlocks(t *testing.T) { + // 内容块数组(含 text + image)应比同长纯文本计入更多 token(图片固定估值) + body := []byte(`{ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": [ + {"type": "text", "text": "describe this"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}} + ]}] + }`) + tokens, err := CountClaudeTokens(body) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tokens < claudeImageTokens { + t.Fatalf("expected image tokens to dominate, got %d", tokens) + } +} + +func TestCountClaudeTokens_SystemAndCJK(t *testing.T) { + // system 文本应计入;CJK 文本 token 密度高于等长拉丁文本 + latin := []byte(`{"model":"m","system":"aaaaaaaaaa","messages":[{"role":"user","content":"x"}]}`) + cjk := []byte(`{"model":"m","system":"你好世界你好世界你好","messages":[{"role":"user","content":"x"}]}`) + lt, err := CountClaudeTokens(latin) + if err != nil { + t.Fatalf("latin err: %v", err) + } + ct, err := CountClaudeTokens(cjk) + if err != nil { + t.Fatalf("cjk err: %v", err) + } + if ct <= lt { + t.Fatalf("expected CJK (%d) to exceed latin (%d) for equal char count", ct, lt) + } +} + +func TestEstimateTextTokens_Empty(t *testing.T) { + if got := estimateTextTokens(""); got != 0 { + t.Fatalf("expected 0 for empty string, got %d", got) + } +} 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/minimax_video.go b/relay/handler/minimax_video.go new file mode 100644 index 0000000..8191ddb --- /dev/null +++ b/relay/handler/minimax_video.go @@ -0,0 +1,329 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + taskminimax "github.com/qianfree/team-api/relay/taskchannel/minimax" +) + +// MiniMax 官方视频协议(/v2)核心处理:查询回放、任务取消。 +// 协议规格见 docs/modeldocs/minimax/video/(02-端点参考.md、04-响应对象.md)。 +// 状态机:queued/running/succeeded/failed/cancelled(网关不主动产生 cancelled 任务; +// 上游任务被取消时经轮询映射为 FAILURE,retrieve 回放为 failed)。 +// 错误用 OpenAI 风格 OaiError(writeTaskError 输出同构,官方 v2 即此风格)。 +// +// DELETE 端点语义(有意与官方分歧): +// - 仅支持取消 queued 任务(取消结果以远程返回为准,上游确认才生效); +// - 不支持删除任务记录(官方对终态任务的 delete 记录能力网关不提供,软删也不做), +// 网关保留任务数据用于计费与审计; +// - 任务终态后 content.url 不再回源刷新(回放缓存的上游直链,有时效)。 + +// minimaxStatusFromTask 平台任务状态 → 官方状态机映射 +func minimaxStatusFromTask(status string) string { + switch status { + case "IN_PROGRESS": + return "running" + case "SUCCESS": + return "succeeded" + case "FAILURE": + return "failed" + default: + // NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued + return "queued" + } +} + +// isMiniMaxTerminalTask 判断是否终态(succeeded/failed 对应的平台态) +func isMiniMaxTerminalTask(status string) bool { + return status == string(common.TaskStatusSuccess) || status == string(common.TaskStatusFailure) +} + +// parseMiniMaxUpstreamTask 解析 task.Data 中缓存的上游单任务查询响应。 +// 提交后首拍轮询前 Data 是提交响应 {"task_id":...} 形态,解析不出 task 时返回 nil(静默降级)。 +func parseMiniMaxUpstreamTask(data []byte) *taskminimax.VideoTask { + if len(data) == 0 { + return nil + } + var resp taskminimax.TaskQueryResponse + if err := json.Unmarshal(data, &resp); err != nil || resp.Task == nil { + return nil + } + return resp.Task +} + +// buildMiniMaxVideoTask 平台任务 → 官方 VideoTask 对象。 +// 任务记录是 id/status/model/时间戳的真相源;usage/resolution/duration/ratio 从 +// 缓存的上游查询响应(task.Data)富集,不可解析时缺省不输出。 +func buildMiniMaxVideoTask(task *common.AsyncTask) *taskminimax.VideoTask { + obj := &taskminimax.VideoTask{ + ID: task.PublicTaskID, + Model: task.ModelName, + Status: minimaxStatusFromTask(task.Status), + CreatedAt: task.CreatedAt.Unix(), + UpdatedAt: task.UpdatedAt.Unix(), + TaskType: "generation", + Modality: "video", + } + + if obj.Status == "failed" { + message := task.FailReason + if message == "" { + message = "video generation failed" + } + obj.Error = &taskminimax.VideoTaskError{Code: "video_generation_failed", Message: message} + } + if task.ResultURL != "" { + obj.Content = &taskminimax.VideoTaskContent{URL: task.ResultURL} + } + + if upstream := parseMiniMaxUpstreamTask(task.Data); upstream != nil { + if upstream.Usage != nil { + obj.Usage = upstream.Usage + } + if upstream.Resolution != "" { + obj.Resolution = upstream.Resolution + } + if upstream.Duration > 0 { + obj.Duration = upstream.Duration + } + if upstream.Ratio != "" { + obj.Ratio = upstream.Ratio + } + // 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底 + if obj.Content == nil && upstream.Content != nil { + obj.Content = upstream.Content + } + } + + return obj +} + +// writeMiniMaxSubmitResponse 提交成功响应:官方 VideoGenerationV2Resp 形态 +func writeMiniMaxSubmitResponse(w http.ResponseWriter, publicTaskID string) { + writeJSON(w, http.StatusOK, map[string]any{"task_id": publicTaskID}) +} + +// fetchMiniMaxTaskById 查询任务并做租户双键隔离 + 平台门禁。 +// 平台门禁:/v2 端点只服务 minimax 平台任务,防止异构平台任务 ID 被塞进官方响应结构回放。 +func fetchMiniMaxTaskById( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) (*common.AsyncTask, bool) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "MiniMaxVideo: query task failed, taskID=%s, err=%v", taskID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return nil, false + } + if task == nil || task.Platform != string(constant.TaskPlatformMiniMax) { + writeTaskError(rc.Writer, http.StatusNotFound, "No MiniMax video task found with id '"+taskID+"'.", "") + return nil, false + } + return task, true +} + +// HandleMiniMaxVideoRetrieve GET /v2/query/video_generation/{task_id} 查询管线。 +// 响应为官方 GetVideoGenerationV2Resp 形态({"task": {...}} 外层包装)。 +func HandleMiniMaxVideoRetrieve( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + writeJSON(rc.Writer, http.StatusOK, taskminimax.TaskQueryResponse{Task: buildMiniMaxVideoTask(task)}) +} + +// HandleMiniMaxVideoCancel DELETE /v2/video_generation/{task_id} 取消管线。 +// 仅支持取消排队中的任务,不支持删除任务记录(含软删,网关保留计费与审计数据)。 +// +// 取消结果以远程返回为准:调用上游 DELETE,上游确认 action=cancelled 才向客户端 +// 返回成功;上游拒绝(如任务已进入 running)时原样透传上游错误。 +// 本地任务状态不在此处改写——轮询循环下一拍从上游查询到 cancelled → FAILURE → 退款, +// 预扣退款与活跃任务计数等记账全部走既有轮询链路,避免在本端点重复实现结算。 +func HandleMiniMaxVideoCancel( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + + // 终态任务既不可取消也不可删除(删除能力网关不提供) + if isMiniMaxTerminalTask(task.Status) { + writeTaskError(rc.Writer, http.StatusBadRequest, + "task is already completed; only queued tasks can be cancelled", "task_not_cancellable") + return + } + // 运行中的任务上游不允许取消,前置拦截省一次上游调用(本地状态若滞后,由上游裁决兜底) + if task.Status == string(common.TaskStatusInProgress) { + writeTaskError(rc.Writer, http.StatusBadRequest, "running tasks cannot be cancelled", "task_not_cancellable") + return + } + // v1(Hailuo 系列)上游没有取消端点 + upstreamModel := task.UpstreamModel + if upstreamModel == "" { + upstreamModel = task.ModelName + } + if !taskminimax.IsV2Model(upstreamModel) { + writeTaskError(rc.Writer, http.StatusBadRequest, + "v1 video tasks cannot be cancelled; the MiniMax v1 API has no cancel endpoint", "task_not_cancellable") + return + } + + // 提取上游任务 ID + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + } + if err := json.Unmarshal(task.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" { + g.Log().Errorf(ctx, "MiniMaxVideoCancel: invalid private data, taskID=%s, err=%v", taskID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid task data", "") + return + } + + // 取渠道信息(上游地址与密钥),对齐 HandleMjImageProxy 的任务→渠道回查模式 + channel, err := dataProvider.GetChannelByID(ctx, task.ChannelID) + if err != nil || channel == nil { + g.Log().Errorf(ctx, "MiniMaxVideoCancel: channel not found, channelID=%d, err=%v", task.ChannelID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "channel not found", "") + return + } + + result, taskErr := taskminimax.CancelTask(channel.BaseURL, channel.ApiKey, pd.UpstreamTaskID, parseMiniMaxUseProxy(channel.Settings)) + if taskErr != nil { + // 远程取消失败(含上游判定运行中/已结束不可取消):以远程结论为准 + g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream rejected, taskID=%s, status=%d, message=%q", taskID, taskErr.StatusCode, taskErr.Message) + writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) + return + } + // 防御竞态:本地检查后任务已在上游完成时,上游会执行删记录(action=deleted)而非取消 + if result.Action != "cancelled" { + g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream action=%s (not cancelled), taskID=%s", result.Action, taskID) + writeTaskError(rc.Writer, http.StatusBadRequest, + "task is no longer queued; only queued tasks can be cancelled", "task_not_cancellable") + return + } + + g.Log().Infof(ctx, "MiniMaxVideoCancel: task cancelled upstream, taskID=%s, upstreamTaskID=%s, tenant=%d, user=%d", + taskID, pd.UpstreamTaskID, rc.TenantID, rc.UserID) + + // 官方 DeleteVideoGenerationV2Resp 形态(取消分支) + writeJSON(rc.Writer, http.StatusOK, map[string]any{ + "task_id": task.PublicTaskID, + "action": "cancelled", + "status": "cancelled", + }) +} + +// parseMiniMaxUseProxy 从渠道 settings JSONB 解析代理开关 +func parseMiniMaxUseProxy(settings json.RawMessage) bool { + var s struct { + UseProxy bool `json:"use_proxy"` + } + if len(settings) > 0 { + _ = json.Unmarshal(settings, &s) + } + return s.UseProxy +} + +// ==================== v1(Hailuo 系列)官方协议 ==================== + +// minimaxV1StatusFromTask 平台任务状态 → v1 官方状态机映射 +// (Preparing/Queueing/Processing/Success/Fail) +func minimaxV1StatusFromTask(status string) string { + switch status { + case "QUEUED": + return "Queueing" + case "IN_PROGRESS": + return "Processing" + case "SUCCESS": + return "Success" + case "FAILURE": + return "Fail" + default: + // NOT_START / SUBMITTED 及未知早期状态 + return "Preparing" + } +} + +// writeMiniMaxV1SubmitResponse v1 提交成功响应:官方 VideoGenerationResp 形态 +// (含 base_resp 成功信封) +func writeMiniMaxV1SubmitResponse(w http.ResponseWriter, publicTaskID string) { + writeJSON(w, http.StatusOK, map[string]any{ + "task_id": publicTaskID, + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + }) +} + +// parseMiniMaxV1UpstreamData 解析 task.Data 中缓存的上游 v1 查询响应(含 FetchTask +// 二跳合并的 download_url)。非 v1 形态(v2 的 {"task":...} 包装或提交响应)返回 nil。 +func parseMiniMaxV1UpstreamData(data []byte) map[string]any { + if len(data) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil + } + if _, hasTask := m["task"]; hasTask { + return nil // v2 形态 + } + if _, hasStatus := m["status"]; !hasStatus { + return nil // 提交响应等非查询形态 + } + return m +} + +// HandleMiniMaxVideoV1Retrieve GET /v1/query/video_generation?task_id= 查询管线。 +// 响应为官方 QueryVideoGenerationTaskResp 形态;附加 download_url(FetchTask 二跳解析的 +// 限时直链,官方查询响应无此字段,属网关增补)与 error(失败原因)两个增补字段, +// 官方 SDK 忽略未知字段,不影响兼容。 +func HandleMiniMaxVideoV1Retrieve( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + + resp := map[string]any{ + "task_id": task.PublicTaskID, + "status": minimaxV1StatusFromTask(task.Status), + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + } + if task.Status == string(common.TaskStatusFailure) && task.FailReason != "" { + resp["error"] = task.FailReason + } + + // 从缓存的上游查询响应富集 file_id/video_width/video_height/download_url + if upstream := parseMiniMaxV1UpstreamData(task.Data); upstream != nil { + for _, key := range []string{"file_id", "video_width", "video_height", "download_url"} { + if v, has := upstream[key]; has { + resp[key] = v + } + } + } + // 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底 + if task.ResultURL != "" { + resp["download_url"] = task.ResultURL + } + + writeJSON(rc.Writer, http.StatusOK, resp) +} diff --git a/relay/handler/minimax_video_convert.go b/relay/handler/minimax_video_convert.go new file mode 100644 index 0000000..fb45845 --- /dev/null +++ b/relay/handler/minimax_video_convert.go @@ -0,0 +1,356 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// MiniMax 官方视频协议(POST /v2/video_generation)入站转换层。 +// 原生请求体解析/校验后转成任务框架通用请求体,复用任务提交管线 +// (selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库)。 +// +// 转换产物在保留原生字段(content[]/resolution/duration/ratio/aigc_watermark)之外, +// 双写通用词汇(prompt/seconds/images[]/metadata)——参数倍率规则的匹配源是归一化 +// 任务体(「规则跟语义走不跟入口协议走」,见 internal/logic/billing/param_multiplier.go), +// 双写保证按通用路径配置的倍率规则对官方协议入站同样生效。 + +// minimaxVideoProtocol TaskRelayContext.Protocol 的 MiniMax 官方视频协议标识 +const minimaxVideoProtocol = "minimax_native" + +// minimaxVideoCreateRequest MiniMax 官方创建请求的归一形态(JSON 解析后的产物) +type minimaxVideoCreateRequest struct { + Model string + Prompt string // content[] 中首个非空 text 项(派生,供计费倍率匹配与审计) + Content []any + Resolution string + Duration int + Ratio string + AigcWatermark *bool +} + +// minimaxVideoRequestEcho 提交时落 PrivateData.request_echo 的请求回显, +// retrieve 时用于回显 prompt/resolution/duration/ratio(任务未轮询到上游数据前的兜底) +type minimaxVideoRequestEcho struct { + Prompt string `json:"prompt"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` + Ratio string `json:"ratio,omitempty"` +} + +// jsonMiniMaxVideoCreateRequest JSON 编码的官方创建请求结构。 +// duration 兼容 number 与 string;content 保留 RawMessage 以校验数组形态。 +type jsonMiniMaxVideoCreateRequest struct { + Model string `json:"model"` + Content json.RawMessage `json:"content"` + Resolution string `json:"resolution"` + Duration json.RawMessage `json:"duration"` + Ratio string `json:"ratio"` + AigcWatermark *bool `json:"aigc_watermark"` +} + +// ParseMiniMaxVideoCreateRequest 解析官方创建请求。 +// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。 +func ParseMiniMaxVideoCreateRequest(body []byte) (*minimaxVideoCreateRequest, *common.TaskError) { + var req jsonMiniMaxVideoCreateRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + out := &minimaxVideoCreateRequest{ + Model: strings.TrimSpace(req.Model), + Resolution: strings.TrimSpace(req.Resolution), + Ratio: strings.TrimSpace(req.Ratio), + AigcWatermark: req.AigcWatermark, + } + + // content 必须是数组 + if len(req.Content) > 0 && string(req.Content) != "null" { + var content []any + if err := json.Unmarshal(req.Content, &content); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid content: expected an array"} + } + out.Content = content + } + + // duration 兼容 number(官方 schema)与 string(宽松兼容) + duration, terr := parseMiniMaxDuration(req.Duration) + if terr != nil { + return nil, terr + } + out.Duration = duration + + // 派生 prompt:content[] 中首个非空 text 项 + for _, item := range out.Content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + out.Prompt = text + break + } + } + } + + return out, nil +} + +// parseMiniMaxDuration 解析 duration 字段(integer 或数字字符串,须为正数) +func parseMiniMaxDuration(raw json.RawMessage) (int, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return 0, nil + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + if d, err := strconv.Atoi(n.String()); err == nil { + return d, nil + } + // 小数秒向下取整(官方档位均为整数,宽松处理) + if f, err := strconv.ParseFloat(n.String(), 64); err == nil && f > 0 { + return int(f), nil + } + } + return 0, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid duration field: must be a positive integer"} +} + +// ValidateMiniMaxVideoCreateRequest 请求必填校验(对齐官方 schema required: +// model / content(含非空 text 项)/ resolution / duration;数值档位差异交给上游裁决) +func ValidateMiniMaxVideoCreateRequest(req *minimaxVideoCreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if len(req.Content) == 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content is required"} + } + if req.Prompt == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"} + } + if req.Resolution == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "resolution is required"} + } + if req.Duration <= 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "duration is required"} + } + return nil +} + +// BuildMiniMaxVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。 +// callback_url 不进入通用任务体(网关轮询计费,上游回调与网关 ID 体系不兼容)。 +func BuildMiniMaxVideoTaskBody(req *minimaxVideoCreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + "prompt": req.Prompt, + "content": req.Content, + "resolution": req.Resolution, + "seconds": strconv.Itoa(req.Duration), // 通用词汇双写(kling/volcengine 消费同一键) + "duration": req.Duration, + "metadata": map[string]any{ + "resolution": req.Resolution, + "duration": req.Duration, + }, + } + if req.Ratio != "" { + body["ratio"] = req.Ratio + body["metadata"].(map[string]any)["ratio"] = req.Ratio + } + if req.AigcWatermark != nil { + body["aigc_watermark"] = *req.AigcWatermark + } + // images[] 双写:content 中首帧/无 role 的 image_url 项提取 URL(图生视频判定与参数倍率用) + if images := extractMiniMaxFrameImages(req.Content); len(images) > 0 { + body["images"] = images + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal minimax video task body: %w", err) + } + return data, nil +} + +// extractMiniMaxFrameImages 从 content[] 提取图片 URL(role=first_frame 或未标 role 的单图) +func extractMiniMaxFrameImages(content []any) []string { + images := make([]string, 0, 2) + for _, item := range content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t != "image_url" { + continue + } + role, _ := m["role"].(string) + if role != "" && role != "first_frame" { + continue + } + if media, ok := m["image_url"].(map[string]any); ok { + if url, ok := media["url"].(string); ok && url != "" { + images = append(images, url) + } + } + } + return images +} + +// NewMiniMaxRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入, +// 提交时随 PrivateData 落库,retrieve 时在上游数据未就绪前回显) +func NewMiniMaxRequestEcho(req *minimaxVideoCreateRequest) minimaxVideoRequestEcho { + return minimaxVideoRequestEcho{ + Prompt: req.Prompt, + Resolution: req.Resolution, + Duration: req.Duration, + Ratio: req.Ratio, + } +} + +// ==================== v1(Hailuo 系列)官方协议入站 ==================== + +// minimaxVideoV1Protocol TaskRelayContext.Protocol 的 MiniMax v1 官方视频协议标识 +const minimaxVideoV1Protocol = "minimax_native_v1" + +// minimaxVideoV1CreateRequest v1 官方创建请求的归一形态(扁平字段协议,无 content[])。 +// resolution/duration 官方可选(有默认值),零值表示未指定。 +type minimaxVideoV1CreateRequest struct { + Model string + Prompt string + FirstFrameImage string + LastFrameImage string + SubjectReference json.RawMessage + Duration int + Resolution string + PromptOptimizer *bool + FastPretreatment *bool + AigcWatermark *bool +} + +// minimaxVideoV1RequestEcho v1 提交回显(任务未轮询到上游数据前的兜底) +type minimaxVideoV1RequestEcho struct { + Prompt string `json:"prompt"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` +} + +// ParseMiniMaxVideoV1CreateRequest 解析 v1 官方创建请求(POST /v1/video_generation,扁平字段)。 +// 文生/图生/首尾帧/主体参考四种形态共用端点,按字段自然区分,duration 兼容 number 与 string。 +func ParseMiniMaxVideoV1CreateRequest(body []byte) (*minimaxVideoV1CreateRequest, *common.TaskError) { + var raw struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + FirstFrameImage string `json:"first_frame_image"` + LastFrameImage string `json:"last_frame_image"` + SubjectReference json.RawMessage `json:"subject_reference"` + Duration json.RawMessage `json:"duration"` + Resolution string `json:"resolution"` + PromptOptimizer *bool `json:"prompt_optimizer"` + FastPretreatment *bool `json:"fast_pretreatment"` + AigcWatermark *bool `json:"aigc_watermark"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + duration, terr := parseMiniMaxDuration(raw.Duration) + if terr != nil { + return nil, terr + } + + return &minimaxVideoV1CreateRequest{ + Model: strings.TrimSpace(raw.Model), + Prompt: raw.Prompt, + FirstFrameImage: strings.TrimSpace(raw.FirstFrameImage), + LastFrameImage: strings.TrimSpace(raw.LastFrameImage), + SubjectReference: raw.SubjectReference, + Duration: duration, + Resolution: strings.TrimSpace(raw.Resolution), + PromptOptimizer: raw.PromptOptimizer, + FastPretreatment: raw.FastPretreatment, + AigcWatermark: raw.AigcWatermark, + }, nil +} + +// ValidateMiniMaxVideoV1CreateRequest 请求必填校验(对齐官方四形态约束): +// model 必填;prompt(文生)/ first_frame_image(图生)/ subject_reference(主体参考)至少其一。 +func ValidateMiniMaxVideoV1CreateRequest(req *minimaxVideoV1CreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if strings.TrimSpace(req.Prompt) == "" && req.FirstFrameImage == "" && len(req.SubjectReference) == 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, + Message: "one of prompt / first_frame_image / subject_reference is required"} + } + return nil +} + +// BuildMiniMaxVideoV1TaskBody 把 v1 官方创建请求转成任务框架通用请求体。 +// callback_url 不进入任务体(网关轮询计费,上游回调与网关 ID 体系不兼容); +// 同时双写通用词汇(seconds/images[]/metadata),保证按通用路径配置的参数倍率规则对 v1 入站同样生效。 +func BuildMiniMaxVideoV1TaskBody(req *minimaxVideoV1CreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + } + if strings.TrimSpace(req.Prompt) != "" { + body["prompt"] = req.Prompt + } + if req.FirstFrameImage != "" { + body["first_frame_image"] = req.FirstFrameImage + body["images"] = []string{req.FirstFrameImage} + } + if req.LastFrameImage != "" { + body["last_frame_image"] = req.LastFrameImage + } + if len(req.SubjectReference) > 0 && string(req.SubjectReference) != "null" { + body["subject_reference"] = req.SubjectReference + } + if req.Resolution != "" { + body["resolution"] = req.Resolution + } + if req.Duration > 0 { + body["duration"] = req.Duration + body["seconds"] = strconv.Itoa(req.Duration) + } + if req.PromptOptimizer != nil { + body["prompt_optimizer"] = *req.PromptOptimizer + } + if req.FastPretreatment != nil { + body["fast_pretreatment"] = *req.FastPretreatment + } + if req.AigcWatermark != nil { + body["aigc_watermark"] = *req.AigcWatermark + } + + // 通用词汇双写(metadata 键) + metadata := map[string]any{} + if req.Resolution != "" { + metadata["resolution"] = req.Resolution + } + if req.Duration > 0 { + metadata["duration"] = req.Duration + } + if req.FirstFrameImage != "" { + metadata["image"] = req.FirstFrameImage + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal minimax v1 video task body: %w", err) + } + return data, nil +} + +// NewMiniMaxV1RequestEcho 从归一请求构造 v1 提交回显 +func NewMiniMaxV1RequestEcho(req *minimaxVideoV1CreateRequest) minimaxVideoV1RequestEcho { + return minimaxVideoV1RequestEcho{ + Prompt: req.Prompt, + Resolution: req.Resolution, + Duration: req.Duration, + } +} diff --git a/relay/handler/minimax_video_convert_test.go b/relay/handler/minimax_video_convert_test.go new file mode 100644 index 0000000..ff3ee50 --- /dev/null +++ b/relay/handler/minimax_video_convert_test.go @@ -0,0 +1,147 @@ +package handler + +import ( + "encoding/json" + "testing" +) + +// MiniMax 官方创建请求的解析 / 校验 / 通用任务体转换单测。 + +func TestParseMiniMaxVideoCreateRequest_Full(t *testing.T) { + body := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石"}, + {"type": "image_url", "image_url": {"url": "https://cdn.example/a.png"}, "role": "first_frame"} + ], + "resolution": "2K", + "duration": 8, + "ratio": "16:9", + "aigc_watermark": true, + "callback_url": "https://example.com/cb" + }` + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(body)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.Model != "MiniMax-H3" || req.Prompt != "海浪拍打礁石" || req.Resolution != "2K" || req.Duration != 8 || req.Ratio != "16:9" { + t.Fatalf("parsed fields: %+v", req) + } + if req.AigcWatermark == nil || !*req.AigcWatermark { + t.Fatalf("aigc_watermark: %v", req.AigcWatermark) + } + if len(req.Content) != 2 { + t.Fatalf("content items: %d", len(req.Content)) + } +} + +func TestParseMiniMaxVideoCreateRequest_DurationString(t *testing.T) { + // duration 宽松兼容字符串数字 + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"6"}`)) + if taskErr != nil || req.Duration != 6 { + t.Fatalf("duration string parse: %+v err=%+v", req, taskErr) + } + // 非法 duration 报 400 + if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"abc"}`)); taskErr == nil { + t.Fatal("expected error for invalid duration") + } +} + +func TestParseMiniMaxVideoCreateRequest_InvalidContent(t *testing.T) { + if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":{"type":"text"},"resolution":"768P","duration":6}`)); taskErr == nil { + t.Fatal("expected error when content is not an array") + } +} + +func TestValidateMiniMaxVideoCreateRequest(t *testing.T) { + valid := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", Prompt: "hi", Content: []any{map[string]any{"type": "text", "text": "hi"}}, + Resolution: "768P", Duration: 6, + } + if taskErr := ValidateMiniMaxVideoCreateRequest(valid); taskErr != nil { + t.Fatalf("valid request should pass: %+v", taskErr) + } + + cases := map[string]*minimaxVideoCreateRequest{ + "missing model": {Prompt: "hi", Resolution: "768P", Duration: 6}, + "missing content": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P", Duration: 6}, + "missing text item": {Model: "MiniMax-H3", Resolution: "768P", Duration: 6}, + "missing resolution": {Model: "MiniMax-H3", Prompt: "hi", Duration: 6}, + "missing duration": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P"}, + } + for name, req := range cases { + if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr == nil { + t.Errorf("%s: expected 400", name) + } + } +} + +func TestBuildMiniMaxVideoTaskBody(t *testing.T) { + req := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", + Prompt: "海浪拍打礁石", + Content: []any{ + map[string]any{"type": "text", "text": "海浪拍打礁石"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/a.png"}, "role": "first_frame"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/ref.png"}, "role": "reference_image"}, + }, + Resolution: "768P", + Duration: 6, + Ratio: "16:9", + } + body, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // 原生字段保留 + if m["model"] != "MiniMax-H3" || m["prompt"] != "海浪拍打礁石" || m["resolution"] != "768P" || m["duration"] != float64(6) || m["ratio"] != "16:9" { + t.Fatalf("native fields: %v", m) + } + if _, has := m["callback_url"]; has { + t.Fatal("callback_url must never enter the task body") + } + + // 通用词汇双写 + if m["seconds"] != "6" { + t.Fatalf("seconds dual-write: %v", m["seconds"]) + } + images, _ := m["images"].([]any) + // 只提取 first_frame / 无 role 的图片,reference_image 不算图生视频输入 + if len(images) != 1 || images[0] != "https://cdn.example/a.png" { + t.Fatalf("images dual-write: %v", m["images"]) + } + meta, _ := m["metadata"].(map[string]any) + if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["ratio"] != "16:9" { + t.Fatalf("metadata dual-write: %v", m["metadata"]) + } + // content 原样保留(3 项) + if content, _ := m["content"].([]any); len(content) != 3 { + t.Fatalf("content preserved: %v", m["content"]) + } +} + +func TestBuildMiniMaxVideoTaskBody_Minimal(t *testing.T) { + // 无 ratio / 无图片:ratio 与 images 键不产出 + req := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", Prompt: "hi", + Content: []any{map[string]any{"type": "text", "text": "hi"}}, + Resolution: "768P", Duration: 4, + } + body, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + _ = json.Unmarshal(body, &m) + if _, has := m["ratio"]; has { + t.Fatalf("ratio should be absent when not specified: %v", m) + } + if _, has := m["images"]; has { + t.Fatalf("images should be absent without image input: %v", m) + } +} diff --git a/relay/handler/minimax_video_pipeline_test.go b/relay/handler/minimax_video_pipeline_test.go new file mode 100644 index 0000000..bfa8d03 --- /dev/null +++ b/relay/handler/minimax_video_pipeline_test.go @@ -0,0 +1,332 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 端到端集成测试:MiniMax 官方协议(/v2)与 OpenAI Videos 协议(/v1/videos)→ 转换 → +// 任务提交管线(minimax 适配器 + mock 上游)→ {"task_id"} 响应 + minimax 平台任务落库 + +// 上游收到映射后的 v2 参数。 + +// newMiniMaxUpstream mock MiniMax v2 上游:记录提交请求体,返回官方提交响应 +func newMiniMaxUpstream(t *testing.T) (*httptest.Server, *map[string]any) { + t.Helper() + received := map[string]any{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/video_generation" { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer sk-test" { + t.Errorf("unexpected authorization: %q", auth) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629"}) + })) + return upstream, &received +} + +func miniMaxChannelMeta(baseURL string) *common.ChannelMeta { + return &common.ChannelMeta{ + ChannelID: 21, + ChannelType: int(constant.ProviderMiniMax), + ChannelName: "mock-minimax", + BaseURL: baseURL, + ApiKey: "sk-test", + UpstreamModelName: "MiniMax-H3", + } +} + +// TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline 官方协议全链路: +// 原生 content[] 透传、callback_url 剥离、{"task_id"} 响应、minimax 平台落库、请求回显持久化。 +func TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline(t *testing.T) { + upstream, received := newMiniMaxUpstream(t) + defer upstream.Close() + + // 官方请求 → 归一 → 通用任务体(与胶水层 HandleMiniMaxVideoSubmit 相同链路) + raw := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石,慢镜头。"}, + {"type": "image_url", "image_url": {"url": "https://cdn.example/first.png"}, "role": "first_frame"} + ], + "resolution": "768P", + "duration": 6, + "ratio": "16:9", + "callback_url": "https://evil.example/cb" + }` + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(raw)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr != nil { + t.Fatalf("validate: %+v", taskErr) + } + taskBody, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_native", + Writer: rec, + Protocol: minimaxVideoProtocol, + RelayMode: int(constant.RelayModeMiniMaxVideo), + RequestEcho: NewMiniMaxRequestEcho(req), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v2/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL)) + + // 客户端响应:官方 VideoGenerationV2Resp 形态 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if len(resp) != 1 { + t.Fatalf("submit response should only carry task_id: %s", rec.Body.String()) + } + taskID, _ := resp["task_id"].(string) + if !strings.HasPrefix(taskID, "task_") { + t.Fatalf("expected task_ prefixed id, got %q", taskID) + } + if rc.TaskID != taskID { + t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID) + } + + // 落库任务:minimax 平台 + 上游任务 ID + 请求回显 + if data.created == nil { + t.Fatal("expected task record created") + } + if data.created.Platform != "minimax" { + t.Fatalf("expected platform minimax, got %q", data.created.Platform) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *minimaxVideoRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "424010985738629" { + t.Fatalf("upstream task id: %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Resolution != "768P" || pd.RequestEcho.Duration != 6 { + t.Fatalf("request echo persisted: %+v", pd.RequestEcho) + } + + // 上游收到:content 原样(2 项)、官方参数、无 callback_url + if (*received)["model"] != "MiniMax-H3" { + t.Fatalf("upstream model: %v", (*received)["model"]) + } + if (*received)["resolution"] != "768P" || (*received)["duration"] != float64(6) || (*received)["ratio"] != "16:9" { + t.Fatalf("upstream params: %v", *received) + } + if _, has := (*received)["callback_url"]; has { + t.Fatal("callback_url must be stripped before forwarding upstream") + } + content, _ := (*received)["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected content passthrough with 2 items, got %v", (*received)["content"]) + } + img, _ := content[1].(map[string]any) + if img["role"] != "first_frame" { + t.Fatalf("content roles preserved: %v", content[1]) + } +} + +// TestHandleTaskSubmit_OpenAIVideosToMiniMax OpenAI Videos 协议 → minimax 适配器: +// size 原值透传(调用方直接传 MiniMax 原生档位词汇)、seconds → duration、multipart 链路复用。 +func TestHandleTaskSubmit_OpenAIVideosToMiniMax(t *testing.T) { + upstream, received := newMiniMaxUpstream(t) + defer upstream.Close() + + // OpenAI multipart 创建请求:size 直传 MiniMax 原生档位 768P + 8 秒 + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "MiniMax-H3", + "prompt": "a cat surfing", + "seconds": "8", + "size": "768P", + }, "", "", nil) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_openai", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL)) + + // OpenAI 协议响应保持官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" { + t.Fatalf("openai video object: %s", rec.Body.String()) + } + if data.created == nil || data.created.Platform != "minimax" { + t.Fatalf("expected minimax task record, got %+v", data.created) + } + + // 上游收到映射后的 v2 参数:size 原值透传、seconds→duration、t2v 默认 ratio + if (*received)["resolution"] != "768P" { + t.Fatalf("upstream resolution passthrough: %v", (*received)["resolution"]) + } + if (*received)["duration"] != float64(8) { + t.Fatalf("upstream duration: %v", (*received)["duration"]) + } + if (*received)["ratio"] != "16:9" { + t.Fatalf("upstream ratio: %v", (*received)["ratio"]) + } + content, _ := (*received)["content"].([]any) + if len(content) != 1 { + t.Fatalf("text-only content expected, got %v", (*received)["content"]) + } + text, _ := content[0].(map[string]any) + if text["text"] != "a cat surfing" { + t.Fatalf("text item: %v", content[0]) + } +} + +// TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline v1 官方协议全链路: +// 扁平字段透传、callback_url 剥离、{task_id, base_resp} 响应、minimax 平台落库。 +func TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline(t *testing.T) { + // mock MiniMax v1 上游:记录提交请求体,返回官方提交响应 + received := map[string]any{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/video_generation" { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "task_id": "v1-up-777", + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + }) + })) + defer upstream.Close() + + raw := `{ + "model": "MiniMax-Hailuo-2.3", + "prompt": "一只猫在月光下的屋顶上行走", + "first_frame_image": "https://cdn.example/f.png", + "duration": 10, + "resolution": "768P", + "callback_url": "https://evil.example/cb" + }` + req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(raw)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil { + t.Fatalf("validate: %+v", taskErr) + } + taskBody, err := BuildMiniMaxVideoV1TaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_v1_native", + Writer: rec, + Protocol: minimaxVideoV1Protocol, + RelayMode: int(constant.RelayModeMiniMaxVideo), + RequestEcho: NewMiniMaxV1RequestEcho(req), + } + data := &stubVideosSubmitProvider{} + channelMeta := &common.ChannelMeta{ + ChannelID: 22, + ChannelType: int(constant.ProviderMiniMax), + ChannelName: "mock-minimax", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "MiniMax-Hailuo-2.3", + } + + HandleTaskSubmit(context.Background(), taskBody, "/v1/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta) + + // 客户端响应:官方 VideoGenerationResp 形态(含 base_resp 成功信封) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskID, _ := resp["task_id"].(string) + if !strings.HasPrefix(taskID, "task_") { + t.Fatalf("expected task_ prefixed id, got %q", taskID) + } + baseResp, _ := resp["base_resp"].(map[string]any) + if baseResp == nil || baseResp["status_code"] != float64(0) { + t.Fatalf("base_resp success envelope: %s", rec.Body.String()) + } + + // 落库任务:minimax 平台 + 上游任务 ID + 请求回显 + if data.created == nil || data.created.Platform != "minimax" { + t.Fatalf("expected minimax task record, got %+v", data.created) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *minimaxVideoV1RequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "v1-up-777" { + t.Fatalf("upstream task id: %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Duration != 10 || pd.RequestEcho.Resolution != "768P" { + t.Fatalf("request echo persisted: %+v", pd.RequestEcho) + } + + // 上游收到:v1 扁平字段、无 content[]、无 callback_url + if received["model"] != "MiniMax-Hailuo-2.3" || received["prompt"] != "一只猫在月光下的屋顶上行走" { + t.Fatalf("upstream flat fields: %v", received) + } + if received["first_frame_image"] != "https://cdn.example/f.png" || received["duration"] != float64(10) || received["resolution"] != "768P" { + t.Fatalf("upstream params: %v", received) + } + if _, has := received["callback_url"]; has { + t.Fatal("callback_url must be stripped before forwarding upstream") + } + if _, has := received["content"]; has { + t.Fatal("v1 request must not carry content[]") + } +} diff --git a/relay/handler/minimax_video_test.go b/relay/handler/minimax_video_test.go new file mode 100644 index 0000000..f6eb679 --- /dev/null +++ b/relay/handler/minimax_video_test.go @@ -0,0 +1,427 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" +) + +// MiniMax 官方协议查询/删除处理器单测(stub TaskDataProvider)。 + +// stubMiniMaxTaskProvider 返回预置任务并回查渠道 +type stubMiniMaxTaskProvider struct { + common.TaskDataProvider + + task *common.AsyncTask + channel *common.ChannelBasicInfo +} + +func (s *stubMiniMaxTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.task != nil && s.task.PublicTaskID == publicTaskID { + return s.task, nil + } + return nil, nil +} + +func (s *stubMiniMaxTaskProvider) GetChannelByID(_ context.Context, _ int64) (*common.ChannelBasicInfo, error) { + return s.channel, nil +} + +// newMiniMaxTask 构造 minimax 平台任务记录(带上游任务 ID,供取消链路使用) +func newMiniMaxTask(status string) *common.AsyncTask { + now := time.Now() + return &common.AsyncTask{ + PublicTaskID: "task_mm_1", + Platform: "minimax", + Status: status, + ModelName: "MiniMax-H3", + TenantID: 1, + UserID: 2, + ChannelID: 21, + PrivateData: json.RawMessage(`{"upstream_task_id":"424010985738629"}`), + CreatedAt: now, + UpdatedAt: now, + } +} + +func TestMiniMaxStatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "queued", + "SUBMITTED": "queued", + "QUEUED": "queued", + "IN_PROGRESS": "running", + "SUCCESS": "succeeded", + "FAILURE": "failed", + "WHATEVER": "queued", + } + for in, want := range cases { + if got := minimaxStatusFromTask(in); got != want { + t.Errorf("minimaxStatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHandleMiniMaxVideoRetrieve_EnrichFromUpstreamData(t *testing.T) { + task := newMiniMaxTask("SUCCESS") + task.ResultURL = "https://video.cdn.example/out.mp4" + task.Data = json.RawMessage(`{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}`) + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj == nil { + t.Fatalf("expected {\"task\": {...}} wrapper: %s", rec.Body.String()) + } + // id 用网关公开 ID(不透出上游 ID) + if taskObj["id"] != "task_mm_1" || taskObj["status"] != "succeeded" { + t.Fatalf("task identity: %s", rec.Body.String()) + } + content, _ := taskObj["content"].(map[string]any) + if content["url"] != "https://video.cdn.example/out.mp4" { + t.Fatalf("content.url: %v", taskObj["content"]) + } + usage, _ := taskObj["usage"].(map[string]any) + if usage["total_tokens"] != float64(273890) { + t.Fatalf("usage replay: %v", taskObj["usage"]) + } + if taskObj["resolution"] != "2K" || taskObj["duration"] != float64(5) || taskObj["ratio"] != "16:9" { + t.Fatalf("enriched specs: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade 提交后首拍轮询前 Data 是提交响应形态, +// 查询必须静默降级(缺失字段不输出,状态以任务记录为准)。 +func TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade(t *testing.T) { + task := newMiniMaxTask("SUBMITTED") + task.Data = json.RawMessage(`{"task_id":"424010985738629"}`) + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj["status"] != "queued" { + t.Fatalf("status from record: %s", rec.Body.String()) + } + if _, has := taskObj["usage"]; has { + t.Fatalf("usage must be absent for submit-shaped data: %s", rec.Body.String()) + } + if _, has := taskObj["resolution"]; has { + t.Fatalf("resolution must be absent for submit-shaped data: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoRetrieve_PlatformGate 平台门禁:异构平台任务 ID 打 /v2 端点按 404 处理 +func TestHandleMiniMaxVideoRetrieve_PlatformGate(t *testing.T) { + task := newMiniMaxTask("SUCCESS") + task.Platform = "kling" + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for non-minimax platform, got %d", rec.Code) + } +} + +// TestHandleMiniMaxVideoRetrieve_FailedTask 失败任务回放官方 error 对象 +func TestHandleMiniMaxVideoRetrieve_FailedTask(t *testing.T) { + task := newMiniMaxTask("FAILURE") + task.FailReason = "content policy violation" + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj["status"] != "failed" { + t.Fatalf("failed status: %s", rec.Body.String()) + } + errObj, _ := taskObj["error"].(map[string]any) + if errObj["message"] != "content policy violation" { + t.Fatalf("error object: %s", rec.Body.String()) + } +} + +// newMiniMaxCancelUpstream mock MiniMax v2 取消端点(DELETE /v2/video_generation/{id}): +// 记录路径与方法,按脚本返回成功 / 上游拒绝 / 竞态删记录三种形态。 +func newMiniMaxCancelUpstream(t *testing.T, script string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("cancel must use DELETE, got %s", r.Method) + } + if r.URL.Path != "/v2/video_generation/424010985738629" { + t.Errorf("unexpected cancel path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch script { + case "ok": + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "cancelled", "status": "cancelled"}) + case "running": + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "running task cannot be cancelled"}}) + case "deleted": + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "deleted", "status": "deleted"}) + } + })) +} + +func TestHandleMiniMaxVideoCancel(t *testing.T) { + // 终态任务:不可取消、不可删除(不做软删),且不触达上游 + upstream := newMiniMaxCancelUpstream(t, "ok") + defer upstream.Close() + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("SUCCESS"), + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("terminal task must not be cancellable/deletable: code=%d body=%s", rec.Code, rec.Body.String()) + } + + // 运行中任务:前置拦截,不可取消 + provider.task = newMiniMaxTask("IN_PROGRESS") + rec = httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("running task must not be cancellable: code=%d body=%s", rec.Code, rec.Body.String()) + } + + // 排队中任务:上游确认取消 → 官方 cancelled 响应 + provider.task = newMiniMaxTask("QUEUED") + rec = httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusOK { + t.Fatalf("queued cancel should succeed: code=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["task_id"] != "task_mm_1" || resp["action"] != "cancelled" || resp["status"] != "cancelled" { + t.Fatalf("cancel response shape: %s", rec.Body.String()) + } + // 本地状态不由取消端点改写(轮询循环对账收敛) + if provider.task.Status != "QUEUED" { + t.Fatalf("cancel endpoint must not mutate local status directly: %s", provider.task.Status) + } +} + +// TestHandleMiniMaxVideoCancel_UpstreamRejected 远程取消失败:上游判定运行中, +// 错误原样透传(本地状态滞后时由上游裁决兜底)。 +func TestHandleMiniMaxVideoCancel_UpstreamRejected(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "running") + defer upstream.Close() + + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("QUEUED"), // 本地状态滞后:本地仍是排队中 + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("upstream rejection must relay: code=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + errObj, _ := resp["error"].(map[string]any) + if errObj["message"] != "running task cannot be cancelled" { + t.Fatalf("upstream message must be relayed: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoCancel_UpstreamDeletedRace 竞态:本地检查后任务已在上游完成, +// 上游执行的是删记录(action=deleted)而非取消——不能向客户端谎报取消成功。 +func TestHandleMiniMaxVideoCancel_UpstreamDeletedRace(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "deleted") + defer upstream.Close() + + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("QUEUED"), + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("deleted action must not be reported as cancelled: code=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoCancel_V1Rejected v1(Hailuo 系列)任务:上游无取消端点,直接拒绝 +func TestHandleMiniMaxVideoCancel_V1Rejected(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "ok") // 上游即使可达也不应被调用 + defer upstream.Close() + + task := newMiniMaxTask("QUEUED") + task.ModelName = "MiniMax-Hailuo-2.3" // UpstreamModel 为空时回落 ModelName 分派 + provider := &stubMiniMaxTaskProvider{ + task: task, + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("v1 cancel must be rejected: code=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "v1 API has no cancel endpoint") { + t.Fatalf("reject message: %s", rec.Body.String()) + } +} + +// ==================== v1(Hailuo 系列)官方协议 ==================== + +func TestMiniMaxV1StatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "Preparing", + "SUBMITTED": "Preparing", + "QUEUED": "Queueing", + "IN_PROGRESS": "Processing", + "SUCCESS": "Success", + "FAILURE": "Fail", + } + for in, want := range cases { + if got := minimaxV1StatusFromTask(in); got != want { + t.Errorf("minimaxV1StatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseAndValidateMiniMaxVideoV1CreateRequest(t *testing.T) { + // 图生视频形态(prompt 可选) + req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(`{ + "model": "MiniMax-Hailuo-2.3", + "first_frame_image": "https://cdn.example/f.png", + "prompt": "让画面动起来", + "duration": "10", + "resolution": "768P", + "prompt_optimizer": false, + "callback_url": "https://example.com/cb" + }`)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.Model != "MiniMax-Hailuo-2.3" || req.FirstFrameImage != "https://cdn.example/f.png" || req.Duration != 10 || req.Resolution != "768P" { + t.Fatalf("parsed fields: %+v", req) + } + if req.PromptOptimizer == nil || *req.PromptOptimizer { + t.Fatalf("prompt_optimizer: %v", req.PromptOptimizer) + } + if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil { + t.Fatalf("i2v form should pass: %+v", taskErr) + } + + // 必填校验:model 必填;prompt/first_frame_image/subject_reference 至少其一 + noModel, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"prompt":"hi"}`)) + if taskErr := ValidateMiniMaxVideoV1CreateRequest(noModel); taskErr == nil { + t.Fatal("expected model required") + } + empty, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"model":"MiniMax-Hailuo-2.3","resolution":"768P"}`)) + if taskErr := ValidateMiniMaxVideoV1CreateRequest(empty); taskErr == nil { + t.Fatal("expected one-of-prompt/image/subject required") + } +} + +func TestBuildMiniMaxVideoV1TaskBody(t *testing.T) { + req := &minimaxVideoV1CreateRequest{ + Model: "MiniMax-Hailuo-2.3", + Prompt: "海浪", + FirstFrameImage: "https://cdn.example/f.png", + Duration: 6, + Resolution: "768P", + } + body, err := BuildMiniMaxVideoV1TaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // v1 扁平字段保留 + if m["model"] != "MiniMax-Hailuo-2.3" || m["prompt"] != "海浪" || m["first_frame_image"] != "https://cdn.example/f.png" { + t.Fatalf("flat fields: %v", m) + } + if m["duration"] != float64(6) || m["resolution"] != "768P" { + t.Fatalf("duration/resolution: %v/%v", m["duration"], m["resolution"]) + } + if _, has := m["callback_url"]; has { + t.Fatal("callback_url must never enter the task body") + } + // 通用词汇双写 + if m["seconds"] != "6" { + t.Fatalf("seconds dual-write: %v", m["seconds"]) + } + images, _ := m["images"].([]any) + if len(images) != 1 || images[0] != "https://cdn.example/f.png" { + t.Fatalf("images dual-write: %v", m["images"]) + } + meta, _ := m["metadata"].(map[string]any) + if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["image"] != "https://cdn.example/f.png" { + t.Fatalf("metadata dual-write: %v", m["metadata"]) + } +} + +func TestHandleMiniMaxVideoV1Retrieve(t *testing.T) { + // 成功任务:官方 QueryVideoGenerationTaskResp 形态 + Data 富集 + ResultURL 直链 + task := newMiniMaxTask("SUCCESS") + task.ModelName = "MiniMax-Hailuo-2.3" + task.ResultURL = "https://cdn.example/out.mp4" + task.Data = json.RawMessage(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"download_url":"https://cdn.example/out.mp4","base_resp":{"status_code":0}}`) + + rec := httptest.NewRecorder() + HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["task_id"] != "task_mm_1" || resp["status"] != "Success" { + t.Fatalf("official shape: %s", rec.Body.String()) + } + if resp["file_id"] != "9001" || resp["video_width"] != float64(1280) || resp["video_height"] != float64(720) { + t.Fatalf("enriched fields: %s", rec.Body.String()) + } + if resp["download_url"] != "https://cdn.example/out.mp4" { + t.Fatalf("download_url: %s", rec.Body.String()) + } + baseResp, _ := resp["base_resp"].(map[string]any) + if baseResp["status_code"] != float64(0) { + t.Fatalf("base_resp success envelope: %s", rec.Body.String()) + } + + // 失败任务:状态 Fail + 增补 error 字段;v2 形态 Data 不富集 + failTask := newMiniMaxTask("FAILURE") + failTask.ModelName = "MiniMax-Hailuo-2.3" + failTask.FailReason = "content violates policy" + failTask.Data = json.RawMessage(`{"task":{"id":"up-1","status":"failed"}}`) // v2 形态:跳过富集 + rec = httptest.NewRecorder() + HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: failTask}) + + resp = decodeVideosJSON(t, rec) + if resp["status"] != "Fail" || resp["error"] != "content violates policy" { + t.Fatalf("failed replay: %s", rec.Body.String()) + } + if _, has := resp["file_id"]; has { + t.Fatalf("v2-shaped data must not enrich v1 response: %s", rec.Body.String()) + } +} 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 e48167a..d8fa77b 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, @@ -261,6 +247,14 @@ func extractSessionSignals(rawRequest map[string]json.RawMessage) dispatch.Sessi return sig } +// newSettleContext 创建结算/退款/调度上报专用上下文:脱离请求取消(客户端断开不得影响结算), +// 且超时预算从调用时刻起算。不可在请求发出前创建后跨 DoRequest/DoResponse 复用—— +// 上游请求与流式转发全程可能远超 30s,提前起算的预算会在结算前耗尽, +// 导致计费记录写入、预扣退款、渠道健康上报拿到已过期的 ctx 而失败。 +func newSettleContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(parent), 30*time.Second) +} + // settleSuccessfulRequest 成功路径的计费结算、健康度更新和用量记录。 func settleSuccessfulRequest( rc *RelayContext, @@ -289,7 +283,10 @@ func settleSuccessfulRequest( } else { g.Log().Warningf(postCtx, "[RelayHandler] Settlement failed for request=%s model=%s, refunding pre-deduct amount=%.6f", rc.RequestID, v.modelName, preDeductAmount) - _ = billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount) + if rErr := billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(postCtx, "[RelayHandler] Refund pre-deduct failed after settlement failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } } @@ -357,14 +354,14 @@ func settleSuccessfulRequest( ImageOutputTokens: tokenDetailField(usage.CompletionTokenDetails, func(d *common.TokenDetails) int { return d.ImageTokens }), // 请求元数据 - RequestedModel: v.modelName, - UpstreamModel: selection.UpstreamModelName, - RequestType: requestType(v.isStream), - UserAgent: headers.Get("User-Agent"), - ClientIP: rc.ClientIP, - FirstTokenMs: firstTokenMs, - ReasoningEffort: info.ReasoningEffort, - InboundEndpoint: path, + RequestedModel: v.modelName, + UpstreamModel: selection.UpstreamModelName, + RequestType: requestType(v.isStream), + UserAgent: headers.Get("User-Agent"), + ClientIP: rc.ClientIP, + FirstTokenMs: firstTokenMs, + InboundEndpoint: path, + UpstreamRequestID: info.UpstreamRequestID, // 渠道详情 ChannelName: selection.ChannelName, @@ -668,8 +665,6 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He leaseRefresh := startDispatchLeaseRefresher(d.Channel.ID, rc.RequestID) upstreamCtx := context.WithoutCancel(attemptCtx) - settleCtx, settleCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer settleCancel() // 发送请求到上游 resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody) @@ -678,6 +673,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // 送达状态标注:连接拒绝/DNS/TLS 建连失败 = 确定未送达; // 写出后 EOF/RST/读超时 = 可能已送达。ReplayUnsafe+MaybeSent 由 FSM 硬规则终止。 delivery := deliveryStateOfRequestErr(err) + // 结算/上报 ctx 此刻新建:上游挂死可耗时数分钟,请求开始时起算的预算早已耗尽 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), 0) trackRetryDecision(dispatchStatusCode(err), err, delivery, decision) @@ -696,9 +694,13 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } if billing != nil && preDeductAmount > 0 { - _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + // 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账 + if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after upstream request failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } - recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus) + recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus, info.UpstreamRequestID) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) dbgAttempt.MarkFinal(err) @@ -709,6 +711,12 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He info.SetFirstResponseTime() } + // 捕获上游请求 ID(所有对话类渠道适配器的响应统一经过此处): + // 流式/非流式响应头在首帧/响应体之前就已就位。存入 info 使失败路径 + // (DoResponse 出错、流中断)的用量记录同样携带,排障错误请求往往更有价值; + // 重试时每次尝试覆盖,终值 = 最后一次尝试。 + info.UpstreamRequestID = helper.ExtractUpstreamRequestID(resp.Header) + // 处理上游响应 usage, err := adaptor.DoResponse(ctx, resp, info, rc.Writer) leaseRefresh.Stop() @@ -720,6 +728,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He rc.RequestID, rc.TenantID, selection.ChannelID, selection.ChannelName, adaptor.GetChannelName(), v.modelName, attempt, info.StreamStatus.Summary(), ctx.Err(), usage, preDeductAmount) // 客户端已收到部分流:不可重试(响应已污染),上报健康后按流中断结算 + // 结算 ctx 此刻新建:流式转发全程可能远超 30s,且请求 ctx 已随客户端断开而取消 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() _, _ = sess.Report(settleCtx, dispatchStatusCode(err), err, dispatch.DeliveryResponseStarted, info.LatencyMs(), 0) streamUsage := usage if streamUsage == nil { @@ -744,7 +755,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } interruptSettle = settleResult } - recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle, info.StreamStatus) + recordFailedUsageWithTokens(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, streamUsage, interruptSettle, info.StreamStatus, info.UpstreamRequestID) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) dbgAttempt.MarkFinal(err) return usage, v.billingResult, err @@ -764,6 +775,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // GetEndReason() != "":流已开始传输后出错,字节已提交给客户端,必须终止 delivery = dispatch.DeliveryResponseStarted } + // 结算/上报 ctx 此刻新建:DoResponse(含长流式转发)可能已消耗任意时长 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), retryAfterOf(err)) trackRetryDecision(dispatchStatusCode(err), err, delivery, decision) @@ -790,9 +804,13 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } if billing != nil && preDeductAmount > 0 { - _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + // 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账 + if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after response failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } - recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus) + recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus, info.UpstreamRequestID) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) dbgAttempt.MarkFinal(err) @@ -801,7 +819,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // 成功路径:绑定续期 + 健康上报由 Finish 完成 dbgAttempt.MarkFinal(nil) - sess.Finish(settleCtx, true, info.LatencyMs()) + finishCtx, finishCancel := newSettleContext(ctx) + defer finishCancel() + sess.Finish(finishCtx, true, info.LatencyMs()) appendHop(trace, hop, true, "", info.LatencyMs()) trace.TotalAttempts = attempt + 1 trace.UpstreamModel = selection.UpstreamModelName @@ -817,25 +837,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, @@ -870,11 +887,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 有状态协议不匹配为预期内错误(上层按哨兵驱动换渠道并向客户端返回 @@ -961,7 +1003,7 @@ func handleChannelUnavailable( "当前模型暂时不可用", constant.ErrAllChannelsFailed, ) - recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr, nil) + recordFailedUsage(provider, rc, nil, v.modelName, v.relayMode, v.isStream, allFailedErr, nil, "") return &channelUnavailableResult{nil, v.billingResult, allFailedErr} } @@ -1063,7 +1105,7 @@ func applyStreamEndDiag(record *common.UsageRecord, streamStatus *common.StreamS // selection 非 nil 时记录具体失败渠道(ID/名称/类型/上游模型),便于在用量日志定位失败渠道; // nil 表示无单一渠道(全部渠道失败),渠道字段留空,失败详情见 error_message。 // streamStatus 非 nil 且已有结束原因时,同步落 stream_end_reason 并修正 error_message。 -func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, streamStatus *common.StreamStatus) { +func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, streamStatus *common.StreamStatus, upstreamRequestID string) { record := &common.UsageRecord{ TenantID: rc.TenantID, UserID: rc.UserID, @@ -1081,6 +1123,7 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection ErrorMessage: helper.SafeUpstreamErrorMessage(err), } applyStreamEndDiag(record, streamStatus, err) + record.UpstreamRequestID = upstreamRequestID if selection != nil { record.ChannelID = selection.ChannelID record.ChannelName = selection.ChannelName @@ -1092,7 +1135,7 @@ func recordFailedUsage(provider common.DataProvider, rc *RelayContext, selection // recordFailedUsageWithTokens 记录失败用量(含 token 明细,用于流中断等已有部分 usage 的场景)。 // 与 recordFailedUsage 的区别:此函数会填充 token 字段,避免报表中流中断记录的 token 全为 0。 -func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult, streamStatus *common.StreamStatus) { +func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, selection *common.ChannelSelection, modelName string, relayMode constant.RelayMode, isStream bool, err error, usage *common.Usage, settleResult *common.SettlementResult, streamStatus *common.StreamStatus, upstreamRequestID string) { record := &common.UsageRecord{ TenantID: rc.TenantID, UserID: rc.UserID, @@ -1110,6 +1153,7 @@ func recordFailedUsageWithTokens(provider common.DataProvider, rc *RelayContext, ErrorMessage: helper.SafeUpstreamErrorMessage(err), } applyStreamEndDiag(record, streamStatus, err) + record.UpstreamRequestID = upstreamRequestID if selection != nil { record.ChannelID = selection.ChannelID record.ChannelName = selection.ChannelName @@ -1177,9 +1221,23 @@ func recordChannelError(rc *RelayContext, selection *common.ChannelSelection, mo commonlogic.DefaultChannelErrorWriter.Submit(event) } -// estimateInputTokens 粗略估算输入 token 数(按字符数 / 4) +// inlineMediaTokenCost 单个内联媒体(base64 图片/音频)在预扣估算中的固定 token 成本。 +// 视觉输入的真实 token 数与分辨率/切片策略强相关(OpenAI 1024px 约 765~1105,Gemini 约 258), +// 这里取保守上界即可——预扣只是闸门,最终以上游 usage 结算,多退少补。 +const inlineMediaTokenCost = 1500 + +// estimateInputTokens 粗略估算输入 token 数(仅用于预扣,结算以上游 usage 为准)。 +// +// 文本部分按字符数 / 4;base64 内联媒体不能按字节折算——一张 2MB 的图按 len/4 +// 会被估成约 70 万 token,预扣直接冻穿余额,余额略少的租户根本发不出请求。 +// 故先扫出内联媒体载荷的字节数从长度中扣除,再按每个媒体的固定成本计入。 func estimateInputTokens(body []byte) int { - return len(body) / 4 + mediaBytes, mediaCount := commonlogic.ScanInlineMediaBytes(body) + textBytes := len(body) - mediaBytes + if textBytes < 0 { + textBytes = 0 + } + return textBytes/4 + mediaCount*inlineMediaTokenCost } // tokenDetailField 安全提取 TokenDetails 中的字段值 @@ -1285,6 +1343,8 @@ func relayModeString(mode constant.RelayMode) string { return "mj_image" case constant.RelayModeVideoGenerations: return "video_generations" + case constant.RelayModeVideos: + return "videos" default: return "" } diff --git a/relay/handler/relay_handler_test.go b/relay/handler/relay_handler_test.go index 5d427c4..aa58234 100644 --- a/relay/handler/relay_handler_test.go +++ b/relay/handler/relay_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "strings" "testing" "github.com/qianfree/team-api/relay/common" @@ -21,76 +22,98 @@ 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 + // 后缀语法已移除:目录名即请求名,不再有「字面模型优先 / 剥后缀回退」两段查找 + 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") + } + }) + + // 名字带 -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) + } + }) +} + +// TestEstimateInputTokens 多模态请求体的预扣估算:base64 内联媒体必须按固定成本计入, +// 不能按字节折算——一张 2MB 的图按 len/4 会被估成约 70 万 token,预扣直接冻穿余额。 +func TestEstimateInputTokens(t *testing.T) { + // 模拟 2MB 的 base64 图片载荷 + payload := strings.Repeat("A", 2<<20) + + cases := []struct { + name string + body string + want int }{ { - 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"}, + "纯文本按字节折算", + strings.Repeat("x", 400), + 100, }, { - 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"}, + "空体", + "", + 0, }, { - name: "model without suffix resolves literally", - model: "gpt-4o", - models: map[string]bool{"gpt-4o": true}, - wantLookup: "gpt-4o", - wantCalls: []string{"gpt-4o"}, + // 文本骨架 40 字节 + 1 个媒体 + "单图不按字节折算", + `{"url":"data:image/png;base64,` + payload + `"}`, + (len(`{"url":"data:image/png;base64,`+`"}`))/4 + inlineMediaTokenCost, }, { - name: "missing literal and base model returns error", - model: "missing-high", - models: map[string]bool{}, - wantCalls: []string{"missing-high", "missing"}, - wantErr: true, + "双图按个数累加", + `{"a":"data:image/png;base64,` + payload + `","b":"data:image/png;base64,` + payload + `"}`, + (len(`{"a":"data:image/png;base64,`+`","b":"data:image/png;base64,`+`"}`))/4 + 2*inlineMediaTokenCost, + }, + { + // 畸形 data URL(无 base64 标记)退化为纯文本路径 + "畸形 data URL", + `{"url":"data:image/png,` + strings.Repeat("A", 400) + `"}`, + len(`{"url":"data:image/png,`+`"}`)/4 + 100, }, } - 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 - } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := estimateInputTokens([]byte(c.body)); got != c.want { + t.Errorf("estimateInputTokens() = %d, want %d", got, c.want) } }) } + + // 回归保护:2MB 图片的估算必须远低于按字节折算的量级 + naive := len(`{"url":"data:image/png;base64,`+payload+`"}`) / 4 + got := estimateInputTokens([]byte(`{"url":"data:image/png;base64,` + payload + `"}`)) + if got >= naive/10 { + t.Errorf("2MB 图片估算 %d tokens,与按字节折算的 %d 处于同一量级,预扣仍会冻穿余额", got, naive) + } } 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/handler/task_handler.go b/relay/handler/task_handler.go index ae180ff..8f4aa23 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -22,6 +22,7 @@ import ( _ "github.com/qianfree/team-api/relay/taskchannel/gemini" _ "github.com/qianfree/team-api/relay/taskchannel/kling" "github.com/qianfree/team-api/relay/taskchannel/midjourney" + _ "github.com/qianfree/team-api/relay/taskchannel/minimax" _ "github.com/qianfree/team-api/relay/taskchannel/sora" _ "github.com/qianfree/team-api/relay/taskchannel/suno" _ "github.com/qianfree/team-api/relay/taskchannel/volcengine" @@ -44,6 +45,20 @@ type TaskRelayContext struct { KeyTotalQuota float64 KeyUsedQuota float64 ForwardingTrace *common.ForwardingTrace + + // Protocol 入站协议标识:"" 默认(legacy 任务响应格式,task_ 前缀 ID); + // videosProtocolOpenAI 切换为 OpenAI Videos 协议(官方 Video 对象响应,video_ 前缀 ID)。 + Protocol string + // RelayMode 任务 relay 模式:0 = 默认 RelayModeVideoGenerations(现有端点), + // OpenAI Videos 入站传 RelayModeVideos 以区分审计与监控统计。 + RelayMode int + // RequestEcho 协议层请求回显(OpenAI Videos 的 prompt/seconds/size), + // 提交时随 PrivateData 落库,retrieve 时回显给客户端;nil = 无回显。 + RequestEcho any + + // Debug 渠道调试日志会话;nil = 渠道未开启调试(所有方法 nil-safe)。 + // 由 HandleTaskSubmit 在提交前创建,响应写完后经 FinalizeAndSubmit 补段4并提交 + Debug *common.DebugSession } func checkTaskIPWhitelist(whitelist string, clientIP string) bool { @@ -92,6 +107,13 @@ func HandleTaskSubmit( billingProvider common.TaskBillingProvider, channelMeta *common.ChannelMeta, ) { + start := time.Now() + // 各出口的响应同步写完后走到 defer:补段4并提交调试记录(幂等、nil-safe)。 + // 必须用闭包——直接 defer 方法调用会在注册时求值 latency 与 rc.Debug(恒为 0/nil) + defer func() { + rc.Debug.FinalizeAndSubmit(time.Since(start).Milliseconds(), 0) + }() + // 0. QPS 限流检查(前置:只依赖认证上下文,超限请求在解析请求体之前被拒绝) allowed, limitLevel, _, _, _ := billingProvider.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps) if !allowed { @@ -154,13 +176,37 @@ func HandleTaskSubmit( g.Log().Debugf(ctx, "HandleTaskSubmit: modelName=%s, platform=%s, channelID=%d, channelType=%d, baseURL=%s, upstreamModel=%s", modelName, platform, channelMeta.ChannelID, channelMeta.ChannelType, channelMeta.BaseURL, channelMeta.UpstreamModelName) // 4. 构建 RelayInfo + relayMode := rc.RelayMode + if relayMode == 0 { + relayMode = int(constant.RelayModeVideoGenerations) + } + + // 渠道调试日志:开关开启且匹配目标过滤时创建会话(捕获段1 + 包装段4 writer), + // 提交为单次尝试(retry_index=0),捕获器经 attemptCtx 注入传输层镜像段2/3。 + // writer 层级:审计 capture(外层,入口胶水包)← DebugClientWriter(内层),与同步链路同构 + var dbgAttempt *common.DebugAttempt + if channelMeta.Settings.DebugLogEnabled && + channelMeta.Settings.DebugTargetMatch(rc.TenantID, rc.UserID, rc.ApiKeyID) { + rc.Debug = common.NewDebugSession(rc.RequestID, rc.TenantID, rc.UserID, rc.ApiKeyID, path) + rc.Debug.CaptureClientRequest(headers, body) + dw := common.NewDebugClientWriter(rc.Writer) + rc.Debug.SetClientWriter(dw) + rc.Writer = dw + dbgAttempt = rc.Debug.BeginTaskAttempt(channelMeta, modelName, + relayModeString(constant.RelayMode(relayMode)), 0) + } + attemptCtx := ctx + if dbgAttempt != nil { + attemptCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture) + } + info := &common.RelayInfo{ - Context: ctx, + Context: attemptCtx, TenantID: rc.TenantID, UserID: rc.UserID, ApiKeyID: rc.ApiKeyID, RequestID: rc.RequestID, - RelayMode: int(constant.RelayModeVideoGenerations), + RelayMode: relayMode, OriginModelName: modelName, RequestURLPath: path, RequestHeaders: headers, @@ -168,6 +214,7 @@ func HandleTaskSubmit( ChannelMeta: channelMeta, } adaptor.Init(info) + dbgAttempt.CaptureProtocol(info) // 5. 校验请求 if taskErr := adaptor.ValidateRequest(ctx, info, body); taskErr != nil { @@ -176,9 +223,10 @@ func HandleTaskSubmit( return } - // 6. 估算计费 + 预扣 + // 6. 估算计费 + 预扣(ratios 为计费上下文:float64 乘数 + spec.* 规格事实值; + // body 同传给 EstimateTaskCost 供参数倍率按归一化任务体匹配,命中注入 ratios) ratios := adaptor.EstimateBilling(ctx, info, body) - estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios) + estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios, body) if err != nil { g.Log().Errorf(ctx, "HandleTaskSubmit: estimate cost failed, model=%s, err=%v", modelName, err) writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "") @@ -205,11 +253,12 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Errorf(ctx, "HandleTaskSubmit: build request failed, model=%s, err=%v", modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusInternalServerError, "build request failed: "+err.Error(), "") return } - resp, err := adaptor.DoRequest(ctx, info, requestBody) + resp, err := adaptor.DoRequest(attemptCtx, info, requestBody) if err != nil { if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil { g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) @@ -217,6 +266,7 @@ func HandleTaskSubmit( // 上游请求失败(网络/超时/拒绝等)属预期内运营事件,非代码 bug: // 用 Warningf 避免 glog 对 ERROR+ 自动打印调用栈污染日志(与 :229 upstream response error 一致)。 g.Log().Warningf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusBadGateway, helper.SafeUpstreamErrorMessage(err), "") return } @@ -231,6 +281,7 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Warningf(ctx, "HandleTaskSubmit: upstream response error, model=%s, status=%d, message=%q, body=%s", modelName, taskErr.StatusCode, taskErr.Message, string(taskData)) + dbgAttempt.MarkFinal(taskErr) writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) return } @@ -238,7 +289,7 @@ func HandleTaskSubmit( // 9. 调整计费 adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData) if adjustedRatios != nil { - newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios) + newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios, body) preDeductAmount, _ = billingProvider.AdjustTaskBilling(ctx, rc.TenantID, rc.RequestID, preDeductAmount, newCost) } @@ -248,7 +299,7 @@ func HandleTaskSubmit( if finalRatios == nil { finalRatios = adjustedRatios } else { - merged := make(map[string]float64, len(finalRatios)+len(adjustedRatios)) + merged := make(map[string]any, len(finalRatios)+len(adjustedRatios)) for k, v := range finalRatios { merged[k] = v } @@ -259,11 +310,24 @@ func HandleTaskSubmit( } } - // 10. 生成公开任务 ID 并创建记录 + // 10. 生成公开任务 ID 并创建记录(OpenAI Videos 协议用 video_ 前缀对齐官方形态) publicTaskID := generatePublicTaskID() + if rc.Protocol == videosProtocolOpenAI { + publicTaskID = generatePublicTaskIDWithPrefix("video") + } now := time.Now() - privateData, _ := json.Marshal(map[string]any{ + // 上游调用追踪 ID:优先响应头(与同步对话链路 helper.ExtractUpstreamRequestID 同源, + // 覆盖 OpenAI/Anthropic 等返回标准头的上游);无候选头时由供应商可选能力从提交 + // 响应体提取(如 DashScope 顶层 request_id)。与 upstream_task_id(任务句柄)互补 + upstreamRequestID := helper.ExtractUpstreamRequestID(resp.Header) + if upstreamRequestID == "" { + if ex, ok := adaptor.(common.UpstreamRequestIDExtractor); ok { + upstreamRequestID = ex.ExtractUpstreamRequestID(taskData) + } + } + + privateDataMap := map[string]any{ "upstream_task_id": upstreamTaskID, "task_type": platform, "billing_context": map[string]any{ @@ -274,7 +338,14 @@ func HandleTaskSubmit( // 进而 Ratios 也读不出、轮询判为「invalid private data」。故此处显式转 float64。 "pre_deduct": preDeductAmount.InexactFloat64(), }, - }) + } + if upstreamRequestID != "" { + privateDataMap["upstream_request_id"] = upstreamRequestID + } + if rc.RequestEcho != nil { + privateDataMap["request_echo"] = rc.RequestEcho + } + privateData, _ := json.Marshal(privateDataMap) task := &common.AsyncTask{ PublicTaskID: publicTaskID, @@ -300,14 +371,35 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Errorf(ctx, "HandleTaskSubmit: create task record failed, publicTaskID=%s, model=%s, err=%v", publicTaskID, modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusInternalServerError, "create task record failed: "+err.Error(), "") return } // 设置 TaskID 供外层审计使用 rc.TaskID = publicTaskID + // 调试日志:提交成功的最终尝试(段4 由 defer 的 FinalizeAndSubmit 补齐) + dbgAttempt.MarkFinal(nil) - // 11. 返回响应 + // 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"} + //(v1 含 base_resp 信封);阿里 DashScope 官方协议返回 {"output":{"task_id":...}}; + // legacy 保持原格式) + if rc.Protocol == videosProtocolOpenAI { + writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho) + return + } + if rc.Protocol == minimaxVideoProtocol { + writeMiniMaxSubmitResponse(rc.Writer, publicTaskID) + return + } + if rc.Protocol == minimaxVideoV1Protocol { + writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID) + return + } + if rc.Protocol == aliVideoProtocol { + writeAliVideoSubmitResponse(rc.Writer, publicTaskID, rc.RequestID, now) + return + } respBody := map[string]any{ "id": publicTaskID, "status": "SUBMITTED", @@ -410,6 +502,11 @@ func generatePublicTaskID() string { return fmt.Sprintf("task_%s", randomHex(32)) } +// generatePublicTaskIDWithPrefix 生成带指定前缀的公开任务 ID(如 OpenAI Videos 协议的 video_ 前缀) +func generatePublicTaskIDWithPrefix(prefix string) string { + return fmt.Sprintf("%s_%s", prefix, randomHex(32)) +} + func randomHex(n int) string { b := make([]byte, (n+1)/2) if _, err := rand.Read(b); err != nil { diff --git a/relay/handler/videos.go b/relay/handler/videos.go new file mode 100644 index 0000000..a221022 --- /dev/null +++ b/relay/handler/videos.go @@ -0,0 +1,305 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" +) + +// OpenAI Videos 协议(/v1/videos)核心处理:Video 对象构建、retrieve / delete / content。 +// 协议规格见 docs/协议文档/OpenAI/OpenAI-Videos-API文档.md。 +// 注意状态机只有 queued/in_progress/completed/failed 四值(无 cancelled), +// 错误分两层:请求层用 HTTP 错误(writeTaskError,OpenAI 格式), +// 生成层失败用 200 + status:"failed" + 内嵌 error 对象表达。 + +// videosProtocolOpenAI TaskRelayContext.Protocol 的 OpenAI Videos 协议标识 +const videosProtocolOpenAI = "openai_videos" + +// videosPollAfterMs 非终态任务 retrieve 响应头 openai-poll-after-ms 的建议轮询间隔(毫秒)。 +// 官方 SDK poll() 优先读取该响应头,缺省时 SDK 自取 1000ms;视频任务耗时长,放慢到 2s。 +const videosPollAfterMs = "2000" + +// videosContentHTTPClient 成品内容回源拉流的 HTTP 客户端(大文件流式转发,整体超时兜底) +var videosContentHTTPClient = &http.Client{Timeout: 120 * time.Second} + +// videoErrorObject 官方 VideoCreateError 的简化形态(misalignment 为 Sora 内容安全特有,第三方上游无对应,不透出) +type videoErrorObject struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// videoObject 官方 Video 对象。可空字段按官方 schema 输出 null(非省略)。 +type videoObject struct { + ID string `json:"id"` + Object string `json:"object"` // 恒为 "video" + CreatedAt int64 `json:"created_at"` + CompletedAt *int64 `json:"completed_at"` + Status string `json:"status"` // queued / in_progress / completed / failed + Progress int `json:"progress"` + Model string `json:"model"` + Prompt *string `json:"prompt"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + Error *videoErrorObject `json:"error"` + RemixedFromVideoID *string `json:"remixed_from_video_id"` + ExpiresAt *int64 `json:"expires_at"` +} + +// videosStatusFromTask 平台任务状态 → 官方四值状态机映射 +func videosStatusFromTask(status string) string { + switch status { + case "IN_PROGRESS": + return "in_progress" + case "SUCCESS": + return "completed" + case "FAILURE": + return "failed" + default: + // NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued + return "queued" + } +} + +// videosProgressFromTask 平台进度("42%" 字符串)→ 官方整数百分比。 +// 解析失败时按状态给粗粒度值(第三方上游无进度信息时的兜底)。 +func videosProgressFromTask(task *common.AsyncTask) int { + if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(task.Progress, "%"))); err == nil { + if n < 0 { + return 0 + } + if n > 100 { + return 100 + } + return n + } + switch videosStatusFromTask(task.Status) { + case "completed": + return 100 + case "in_progress": + return 50 + default: + return 0 + } +} + +// videosEchoFromTask 从 PrivateData.request_echo 提取提交时的请求回显 +func videosEchoFromTask(task *common.AsyncTask) videosRequestEcho { + var echo videosRequestEcho + if len(task.PrivateData) == 0 { + return echo + } + var pd struct { + RequestEcho *videosRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(task.PrivateData, &pd); err == nil && pd.RequestEcho != nil { + echo = *pd.RequestEcho + } + return echo +} + +// buildVideoObject 平台任务 → 官方 Video 对象 +func buildVideoObject(task *common.AsyncTask) *videoObject { + obj := &videoObject{ + ID: task.PublicTaskID, + Object: "video", + CreatedAt: task.CreatedAt.Unix(), + Status: videosStatusFromTask(task.Status), + Progress: videosProgressFromTask(task), + Model: task.ModelName, + } + + echo := videosEchoFromTask(task) + prompt := echo.Prompt + obj.Prompt = &prompt + obj.Seconds = echo.Seconds + obj.Size = echo.Size + + if task.FinishTime != nil { + completedAt := task.FinishTime.Unix() + obj.CompletedAt = &completedAt + } + if obj.Status == "failed" { + message := task.FailReason + if message == "" { + message = "video generation failed" + } + obj.Error = &videoErrorObject{Code: "video_generation_failed", Message: message} + } + return obj +} + +// writeVideosSubmitResponse 提交成功响应:官方 Video 对象(初始 queued / progress 0) +func writeVideosSubmitResponse(w http.ResponseWriter, publicTaskID, modelName string, now time.Time, echo any) { + obj := &videoObject{ + ID: publicTaskID, + Object: "video", + CreatedAt: now.Unix(), + Status: "queued", + Progress: 0, + Model: modelName, + } + if echo != nil { + if e, ok := echo.(videosRequestEcho); ok { + prompt := e.Prompt + obj.Prompt = &prompt + obj.Seconds = e.Seconds + obj.Size = e.Size + } + } + writeJSON(w, http.StatusOK, obj) +} + +// HandleVideosRetrieve GET /v1/videos/{video_id} 查询管线。 +// 租户双键隔离(user_id + tenant_id)复用 GetTaskByPublicIDAndUser;软删除任务已被过滤,天然 404。 +func HandleVideosRetrieve( + ctx context.Context, + videoID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosRetrieve: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + obj := buildVideoObject(task) + // 非终态附带官方轮询提示头(SDK poll() 优先读取) + if obj.Status == "queued" || obj.Status == "in_progress" { + rc.Writer.Header().Set("openai-poll-after-ms", videosPollAfterMs) + } + writeJSON(rc.Writer, http.StatusOK, obj) +} + +// HandleVideosDelete DELETE /v1/videos/{video_id} 删除管线。 +// 对齐官方语义「永久删除已完成/失败的视频及其资产」:仅终态可删; +// 网关侧软删除保留计费与审计记录,删除后查询/下载均按 404 处理(幂等)。 +func HandleVideosDelete( + ctx context.Context, + videoID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosDelete: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + // 仅终态(completed/failed)可删,进行中任务官方亦不允许删除 + if videosStatusFromTask(task.Status) != "completed" && videosStatusFromTask(task.Status) != "failed" { + writeTaskError(rc.Writer, http.StatusBadRequest, "only completed or failed videos can be deleted", "video_not_deletable") + return + } + // 未结算的终态任务删除会造成退款悬空:结算由轮询 cron 兜底完成后即可删除 + if !task.BillingSettled { + writeTaskError(rc.Writer, http.StatusBadRequest, "video billing is not settled yet, retry later", "billing_pending") + return + } + + if err := dataProvider.SoftDeleteTask(ctx, task); err != nil { + g.Log().Errorf(ctx, "HandleVideosDelete: soft delete failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "delete video failed", "") + return + } + g.Log().Infof(ctx, "HandleVideosDelete: video deleted, videoID=%s, tenant=%d, user=%d", videoID, rc.TenantID, rc.UserID) + + writeJSON(rc.Writer, http.StatusOK, map[string]any{ + "id": task.PublicTaskID, + "object": "video.deleted", + "deleted": true, + }) +} + +// HandleVideosContent GET /v1/videos/{video_id}/content 成品下载管线。 +// 从任务 ResultURL 回源拉流并转发二进制(不透出上游 CDN 直链)。 +// 仅支持官方默认 variant=video;thumbnail/spritesheet 第三方上游无此资产。 +func HandleVideosContent( + ctx context.Context, + videoID string, + variant string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + if variant != "" && variant != "video" { + writeTaskError(rc.Writer, http.StatusBadRequest, "unsupported content variant: "+variant, "unsupported_variant") + return + } + + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosContent: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + // 官方语义:仅 completed 可下载(未完成/失败均报错) + if task.Status != "SUCCESS" { + writeTaskError(rc.Writer, http.StatusBadRequest, "video content is not available until the video is completed", "video_not_ready") + return + } + // 部分上游(如官方 sora)不提供成品直链,需要二跳 content 拉取——当前不支持,明确报错 + if task.ResultURL == "" { + writeTaskError(rc.Writer, http.StatusBadRequest, "video content is not available for this task", "content_not_available") + return + } + + // ResultURL 来自上游响应持久化数据,仍做 scheme 白名单防异构值 + resultURL, err := url.Parse(task.ResultURL) + if err != nil || (resultURL.Scheme != "http" && resultURL.Scheme != "https") { + g.Log().Errorf(ctx, "HandleVideosContent: invalid result url, videoID=%s, url=%q, err=%v", videoID, task.ResultURL, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid video result url", "") + return + } + + resp, err := videosContentHTTPClient.Get(task.ResultURL) + if err != nil { + // 上游拉流失败属预期内运营事件,用 Warningf 避免自动打栈(对齐任务提交链路日志分级) + g.Log().Warningf(ctx, "HandleVideosContent: fetch upstream failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusBadGateway, "failed to fetch video content", "") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + g.Log().Warningf(ctx, "HandleVideosContent: upstream status=%d, videoID=%s", resp.StatusCode, videoID) + writeTaskError(rc.Writer, http.StatusBadGateway, "failed to fetch video content", "") + return + } + + // 透传内容元信息后流式转发字节 + if ct := resp.Header.Get("Content-Type"); ct != "" { + rc.Writer.Header().Set("Content-Type", ct) + } + if cl := resp.Header.Get("Content-Length"); cl != "" { + rc.Writer.Header().Set("Content-Length", cl) + } + rc.Writer.WriteHeader(http.StatusOK) + if _, err := io.Copy(rc.Writer, resp.Body); err != nil { + // 响应头已发出,只能记录日志,无法再改写为错误响应 + g.Log().Warningf(ctx, "HandleVideosContent: stream copy failed, videoID=%s, err=%v", videoID, err) + } +} diff --git a/relay/handler/videos_convert.go b/relay/handler/videos_convert.go new file mode 100644 index 0000000..f0bfb93 --- /dev/null +++ b/relay/handler/videos_convert.go @@ -0,0 +1,367 @@ +package handler + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// OpenAI Videos 协议(/v1/videos)入站转换层。 +// 官方 SDK(openai-python / openai-node)对 POST /v1/videos 一律发送 multipart/form-data, +// 裸 HTTP 客户端常用 JSON——两种编码都要支持;解析后统一转成任务框架的通用请求体 +// {model, prompt, seconds, ratio, metadata{duration, size, ratio, aspect_ratio, sound, generate_audio, image}}, +// 由各 taskchannel 适配器按需消费(kling 读顶层 seconds,volcengine 读 metadata.duration 等)。 +// size / aspect_ratio / sound 均为协议扩展字段(官方 Videos 协议无此三项), +// 取值直接透传目标供应商的原生词汇,不做档位换算与白名单校验。 + +// videosInputReferenceMaxBytes 参考图上传大小上限。 +// GoFrame server 默认 ClientMaxBodySize 8MB,base64 膨胀 4/3 后须留余量,取 6MB。 +const videosInputReferenceMaxBytes = 6 << 20 + +// videosCreateRequest OpenAI Videos 创建请求的归一形态(multipart/JSON 解析后的产物) +type videosCreateRequest struct { + Model string + Prompt string + Seconds string // 统一归一为字符串(官方协议为 "4"/"8"/"12");空 = 未指定 + Size string // 原样保留(如 "1280x720");空 = 未指定 + InputReference string // 参考图:data URL 或 http(s) URL;空 = 无 + AspectRatio string // 屏幕比例(如 "16:9"/"9:16");空 = 未指定(由适配器取各自默认档) + Sound string // 音频开关,归一为 "on"/"off";空 = 未指定(由上游取默认) +} + +// videosRequestEcho 提交时落 PrivateData.request_echo 的请求回显, +// retrieve(GET /v1/videos/{id})时用于回显 prompt/seconds/size(上游响应不含这些字段)。 +type videosRequestEcho struct { + Prompt string `json:"prompt"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Sound string `json:"sound,omitempty"` +} + +// jsonVideosCreateRequest JSON 编码的创建请求结构。 +// input_reference 保留 RawMessage 以区分 {file_id} / {image_url} 两种引用形态。 +type jsonVideosCreateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Seconds json.RawMessage `json:"seconds"` // string 或 number + Size string `json:"size"` + InputReference json.RawMessage `json:"input_reference"` + AspectRatio string `json:"aspect_ratio"` + Sound json.RawMessage `json:"sound"` // "on"/"off" 或 true/false + GenerateAudio json.RawMessage `json:"generate_audio"` // true/false(generate_audio 词汇,sound 缺省时生效) +} + +// ParseVideosCreateRequest 解析 OpenAI Videos 创建请求(multipart 或 JSON)。 +// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。 +func ParseVideosCreateRequest(body []byte, contentType string) (*videosCreateRequest, *common.TaskError) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType // 非法 Content-Type 按 JSON 尝试解析 + } + + if strings.HasPrefix(mediaType, "multipart/") { + return parseVideosCreateMultipart(body, params["boundary"]) + } + return parseVideosCreateJSON(body) +} + +// parseVideosCreateJSON 解析 JSON 编码的创建请求 +func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError) { + var req jsonVideosCreateRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + out := &videosCreateRequest{ + Model: strings.TrimSpace(req.Model), + Prompt: req.Prompt, + Size: strings.TrimSpace(req.Size), + AspectRatio: strings.TrimSpace(req.AspectRatio), + } + + seconds, terr := normalizeVideosSeconds(req.Seconds) + if terr != nil { + return nil, terr + } + out.Seconds = seconds + + // 音频开关:sound 更明确,优先;缺省时回退 generate_audio + sound, terr := normalizeVideosSound(req.Sound) + if terr != nil { + return nil, terr + } + if sound == "" { + sound, terr = normalizeVideosSound(req.GenerateAudio) + if terr != nil { + return nil, terr + } + } + out.Sound = sound + + ref, terr := parseVideosInputReferenceJSON(req.InputReference) + if terr != nil { + return nil, terr + } + out.InputReference = ref + + return out, nil +} + +// parseVideosCreateMultipart 解析 multipart/form-data 编码的创建请求(官方 SDK 形态)。 +// input_reference 可能是文件 part(上传),也可能是字符串 part(引用对象 JSON)。 +func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequest, *common.TaskError) { + if boundary == "" { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "missing multipart boundary"} + } + + form, err := multipart.NewReader(bytes.NewReader(body), boundary).ReadForm(videosInputReferenceMaxBytes) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid multipart form: " + err.Error()} + } + defer form.RemoveAll() + + formValue := func(name string) string { + if vals := form.Value[name]; len(vals) > 0 { + return vals[0] + } + return "" + } + + out := &videosCreateRequest{ + Model: strings.TrimSpace(formValue("model")), + Prompt: formValue("prompt"), + Size: strings.TrimSpace(formValue("size")), + AspectRatio: strings.TrimSpace(formValue("aspect_ratio")), + } + + seconds, terr := normalizeVideosSeconds(json.RawMessage(formValue("seconds"))) + if terr != nil { + return nil, terr + } + out.Seconds = seconds + + // multipart 的 part 值是裸字符串,包成 JSON 字符串后再走同一归一逻辑 + sound, terr := normalizeVideosSound(multipartJSONString(formValue("sound"))) + if terr != nil { + return nil, terr + } + if sound == "" { + sound, terr = normalizeVideosSound(multipartJSONString(formValue("generate_audio"))) + if terr != nil { + return nil, terr + } + } + out.Sound = sound + + // input_reference:优先文件 part,其次字符串 part(引用对象 JSON) + if files := form.File["input_reference"]; len(files) > 0 { + fh := files[0] + f, err := fh.Open() + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "failed to open input_reference file"} + } + defer f.Close() + + raw, err := io.ReadAll(io.LimitReader(f, videosInputReferenceMaxBytes+1)) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "failed to read input_reference file"} + } + if len(raw) > videosInputReferenceMaxBytes { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "input_reference file too large, max 6MB"} + } + if len(raw) == 0 { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "input_reference file is empty"} + } + + // 内容类型探测优先于 part 声明的 Content-Type(客户端常误标 application/octet-stream) + ct := fh.Header.Get("Content-Type") + if ct == "" || ct == "application/octet-stream" { + ct = http.DetectContentType(raw) + } + out.InputReference = "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw) + } else if v := formValue("input_reference"); v != "" { + ref, terr := parseVideosInputReferenceJSON(json.RawMessage(v)) + if terr != nil { + return nil, terr + } + out.InputReference = ref + } + + return out, nil +} + +// normalizeVideosSeconds 归一 seconds 字段:接受字符串(官方形态)或数字(宽松兼容),统一转字符串。 +// 不做 4/8/12 白名单校验——第三方视频模型时长档位各异,透传给适配器自行对齐。 +func normalizeVideosSeconds(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 { + return "", nil + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s), nil + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + if _, convErr := strconv.ParseFloat(n.String(), 64); convErr == nil { + return n.String(), nil + } + } + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid seconds field: must be a string or number"} +} + +// multipartJSONString 把 multipart part 的裸字符串包成 JSON 字符串字面量, +// 空值返回 nil(视为未传)。这样 multipart 与 JSON 两种编码共用同一套归一逻辑。 +func multipartJSONString(v string) json.RawMessage { + if strings.TrimSpace(v) == "" { + return nil + } + b, err := json.Marshal(v) + if err != nil { + return nil + } + return b +} + +// normalizeVideosSound 归一音频开关:接受 "on"/"off"、"true"/"false"、"1"/"0" +// 以及布尔字面量,统一转成 "on"/"off"(可灵的上游词汇)。空 = 未指定。 +// 不做厂商白名单校验——各上游对音频开关的词汇与默认值不同,归一后由适配器按需消费。 +func normalizeVideosSound(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + if b { + return "on", nil + } + return "off", nil + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + switch strings.ToLower(strings.TrimSpace(s)) { + case "": + return "", nil + case "on", "true", "1", "yes": + return "on", nil + case "off", "false", "0", "no": + return "off", nil + } + } + + return "", &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: `invalid sound field: must be "on"/"off" or true/false`, + } +} + +// parseVideosInputReferenceJSON 解析引用对象形态的 input_reference: +// - {"image_url": "https://... 或 data:..."} → 原样透传 +// - {"file_id": "..."} → 不支持(平台无 Files API),返回 400 引导改用 image_url/文件上传 +func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + + var ref struct { + FileID string `json:"file_id"` + ImageURL string `json:"image_url"` + } + if err := json.Unmarshal(raw, &ref); err != nil { + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid input_reference: expected file upload or {image_url}"} + } + switch { + case ref.ImageURL != "": + return ref.ImageURL, nil + case ref.FileID != "": + return "", &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: "input_reference.file_id is not supported; upload the file directly or use image_url instead", + ErrCode: "file_id_not_supported", + } + default: + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid input_reference: expected file upload or {image_url}"} + } +} + +// BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。 +// 顶层保留 seconds + ratio + images,metadata 写入 duration/size/ratio/aspect_ratio/sound/generate_audio/image。 +// size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的 +// 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。 +func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + "prompt": req.Prompt, + } + metadata := map[string]any{} + + if req.Seconds != "" { + body["seconds"] = req.Seconds + if d, err := strconv.Atoi(req.Seconds); err == nil { + metadata["duration"] = d + } + } + if req.Size != "" { + metadata["size"] = req.Size + } + if req.AspectRatio != "" { + // 比例双写:minimax/volcengine 读 metadata.ratio,kling 读 metadata.aspect_ratio, + // minimax 的 resolveRatio 顶层 ratio 优先级最高,故三处同写 + metadata["ratio"] = req.AspectRatio + metadata["aspect_ratio"] = req.AspectRatio + body["ratio"] = req.AspectRatio + } + if req.Sound != "" { + // 音频开关双写:kling 读 metadata.sound("on"/"off"),volcengine 读 metadata.generate_audio(bool) + metadata["sound"] = req.Sound + metadata["generate_audio"] = req.Sound == "on" + } + if req.InputReference != "" { + metadata["image"] = req.InputReference + // volcengine 图生视频读根级 images 数组(kling 亦有该兜底) + body["images"] = []string{req.InputReference} + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal videos task body: %w", err) + } + return data, nil +} + +// ValidateVideosCreateRequest 请求必填校验(model/prompt 官方均为必填) +func ValidateVideosCreateRequest(req *videosCreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if strings.TrimSpace(req.Prompt) == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"} + } + return nil +} + +// NewVideosRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入, +// 提交时随 PrivateData 落库,retrieve 时回显 prompt/seconds/size) +func NewVideosRequestEcho(req *videosCreateRequest) videosRequestEcho { + return videosRequestEcho{ + Prompt: req.Prompt, + Seconds: req.Seconds, + Size: req.Size, + AspectRatio: req.AspectRatio, + Sound: req.Sound, + } +} diff --git a/relay/handler/videos_convert_test.go b/relay/handler/videos_convert_test.go new file mode 100644 index 0000000..cc702e7 --- /dev/null +++ b/relay/handler/videos_convert_test.go @@ -0,0 +1,348 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/textproto" + "strings" + "testing" +) + +// buildMultipartVideosBody 构造 multipart/form-data 的创建请求体(模拟官方 SDK 形态)。 +// 文件 part 的 Content-Type 统一声明为 application/octet-stream,以覆盖内容类型嗅探路径。 +func buildMultipartVideosBody(t *testing.T, fields map[string]string, fileField, fileName string, fileBytes []byte) ([]byte, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("write field %s: %v", k, err) + } + } + if fileField != "" { + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileField, fileName)) + h.Set("Content-Type", "application/octet-stream") + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("create file part: %v", err) + } + if _, err := part.Write(fileBytes); err != nil { + t.Fatalf("write file part: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + return buf.Bytes(), w.FormDataContentType() +} + +func TestParseVideosCreateRequest_JSON(t *testing.T) { + body := `{"model":"kling-v2-master","prompt":"a cat","seconds":"8","size":"1280x720","input_reference":{"image_url":"https://example.com/ref.png"}}` + req, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "kling-v2-master" || req.Prompt != "a cat" || req.Seconds != "8" || req.Size != "1280x720" { + t.Fatalf("unexpected request: %+v", req) + } + if req.InputReference != "https://example.com/ref.png" { + t.Fatalf("unexpected input_reference: %s", req.InputReference) + } +} + +func TestParseVideosCreateRequest_JSONSecondsNumber(t *testing.T) { + body := `{"model":"m","prompt":"p","seconds":8}` + req, taskErr := ParseVideosCreateRequest([]byte(body), "application/json; charset=utf-8") + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Seconds != "8" { + t.Fatalf("expected seconds normalized to \"8\", got %q", req.Seconds) + } +} + +func TestParseVideosCreateRequest_JSONInvalidSeconds(t *testing.T) { + body := `{"model":"m","prompt":"p","seconds":true}` + _, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr == nil || taskErr.StatusCode != 400 { + t.Fatalf("expected 400 for bool seconds, got %+v", taskErr) + } +} + +func TestParseVideosCreateRequest_JSONFileIDRejected(t *testing.T) { + body := `{"model":"m","prompt":"p","input_reference":{"file_id":"file-abc"}}` + _, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr == nil { + t.Fatal("expected file_id to be rejected") + } + if taskErr.StatusCode != 400 || taskErr.ErrCode != "file_id_not_supported" { + t.Fatalf("unexpected error: %+v", taskErr) + } +} + +func TestParseVideosCreateRequest_MultipartWithFile(t *testing.T) { + // PNG 魔数,DetectContentType 应识别为 image/png(part 声明为 octet-stream) + pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D} + body, ct := buildMultipartVideosBody(t, + map[string]string{"model": "kling-v2-master", "prompt": "a cat", "seconds": "12", "size": "720x1280"}, + "input_reference", "ref.png", pngBytes) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "kling-v2-master" || req.Seconds != "12" || req.Size != "720x1280" { + t.Fatalf("unexpected request: %+v", req) + } + if !strings.HasPrefix(req.InputReference, "data:image/png;base64,") { + t.Fatalf("expected data url with sniffed image/png, got %.40q", req.InputReference) + } +} + +func TestParseVideosCreateRequest_MultipartStringRef(t *testing.T) { + body, ct := buildMultipartVideosBody(t, + map[string]string{ + "model": "m", + "prompt": "p", + "input_reference": `{"image_url":"https://example.com/x.jpg"}`, + }, "", "", nil) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.InputReference != "https://example.com/x.jpg" { + t.Fatalf("unexpected input_reference: %s", req.InputReference) + } +} + +// TestBuildVideosTaskBody_SizePassthrough size 只借协议字段形态、不做档位换算: +// 原值进 metadata.size,不派生 aspect_ratio/ratio/resolution(值词汇由调用方按目标供应商原生口径提供)。 +func TestBuildVideosTaskBody_SizePassthrough(t *testing.T) { + req := &videosCreateRequest{ + Model: "kling-v2-master", + Prompt: "a cat", + Seconds: "8", + Size: "768P", + InputReference: "https://example.com/ref.png", + } + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["model"] != "kling-v2-master" || parsed["prompt"] != "a cat" { + t.Fatalf("unexpected body: %s", body) + } + if parsed["seconds"] != "8" { + t.Fatalf("expected top-level seconds \"8\", got %v", parsed["seconds"]) + } + + meta, ok := parsed["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object: %s", body) + } + if meta["duration"] != float64(8) { + t.Fatalf("expected metadata.duration 8, got %v", meta["duration"]) + } + if meta["size"] != "768P" { + t.Fatalf("expected raw size passthrough, got %v", meta["size"]) + } + // 不做换算:不从 size 派生任何其他参数键 + for _, key := range []string{"aspect_ratio", "ratio", "resolution"} { + if _, has := meta[key]; has { + t.Fatalf("size must not derive %s: %s", key, body) + } + } + if meta["image"] != "https://example.com/ref.png" { + t.Fatalf("expected metadata.image, got %v", meta["image"]) + } + images, ok := parsed["images"].([]any) + if !ok || len(images) != 1 || images[0] != "https://example.com/ref.png" { + t.Fatalf("expected root images array with reference, got %v", parsed["images"]) + } +} + +func TestBuildVideosTaskBody_Minimal(t *testing.T) { + req := &videosCreateRequest{Model: "m", Prompt: "p"} + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := parsed["seconds"]; ok { + t.Fatal("expected no seconds field") + } + if _, ok := parsed["images"]; ok { + t.Fatal("expected no images field without input_reference") + } + if _, ok := parsed["metadata"]; ok { + t.Fatal("expected no metadata field") + } +} + +func TestBuildVideosTaskBody_UnknownSizeNoRatio(t *testing.T) { + req := &videosCreateRequest{Model: "m", Prompt: "p", Size: "1920x1080"} + body, _ := BuildVideosTaskBody(req) + var parsed map[string]any + json.Unmarshal(body, &parsed) + meta := parsed["metadata"].(map[string]any) + if _, ok := meta["aspect_ratio"]; ok { + t.Fatal("unknown size must not map aspect_ratio") + } + if _, ok := meta["ratio"]; ok { + t.Fatal("unknown size must not map ratio") + } + if _, ok := meta["resolution"]; ok { + t.Fatal("unknown size must not map resolution") + } + if meta["size"] != "1920x1080" { + t.Fatalf("expected raw size passthrough, got %v", meta["size"]) + } +} + +func TestValidateVideosCreateRequest(t *testing.T) { + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Model: "m", Prompt: "p"}); taskErr != nil { + t.Fatalf("unexpected: %+v", taskErr) + } + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Prompt: "p"}); taskErr == nil || taskErr.Message != "model is required" { + t.Fatalf("expected model required, got %+v", taskErr) + } + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Model: "m", Prompt: " "}); taskErr == nil || taskErr.Message != "prompt is required" { + t.Fatalf("expected prompt required, got %+v", taskErr) + } +} + +// TestParseVideosCreateRequest_JSONAspectRatioAndSound 扩展字段(aspect_ratio/sound)解析与归一 +func TestParseVideosCreateRequest_JSONAspectRatioAndSound(t *testing.T) { + cases := []struct { + name string + body string + ratio string + sound string + wantErr bool + }{ + {name: "string on", body: `{"model":"m","prompt":"p","aspect_ratio":"9:16","sound":"on"}`, ratio: "9:16", sound: "on"}, + {name: "string off", body: `{"model":"m","prompt":"p","sound":"off"}`, sound: "off"}, + {name: "bool true", body: `{"model":"m","prompt":"p","sound":true}`, sound: "on"}, + {name: "bool false", body: `{"model":"m","prompt":"p","sound":false}`, sound: "off"}, + {name: "generate_audio fallback", body: `{"model":"m","prompt":"p","generate_audio":true}`, sound: "on"}, + // sound 更明确,同时存在时 sound 优先 + {name: "sound wins over generate_audio", body: `{"model":"m","prompt":"p","sound":"off","generate_audio":true}`, sound: "off"}, + {name: "absent", body: `{"model":"m","prompt":"p"}`}, + {name: "invalid sound", body: `{"model":"m","prompt":"p","sound":"loud"}`, wantErr: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req, taskErr := ParseVideosCreateRequest([]byte(c.body), "application/json") + if c.wantErr { + if taskErr == nil || taskErr.StatusCode != 400 { + t.Fatalf("expected 400, got %+v", taskErr) + } + return + } + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.AspectRatio != c.ratio { + t.Fatalf("expected aspect_ratio %q, got %q", c.ratio, req.AspectRatio) + } + if req.Sound != c.sound { + t.Fatalf("expected sound %q, got %q", c.sound, req.Sound) + } + }) + } +} + +// TestParseVideosCreateRequest_MultipartAspectRatioAndSound multipart 裸字符串 part 走同一归一逻辑 +func TestParseVideosCreateRequest_MultipartAspectRatioAndSound(t *testing.T) { + body, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "kling-v2-master", + "prompt": "a cat", + "aspect_ratio": "1:1", + "sound": "true", + }, "", "", nil) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.AspectRatio != "1:1" || req.Sound != "on" { + t.Fatalf("unexpected request: %+v", req) + } +} + +// TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite 比例与音频开关需按各上游词汇双写 +func TestBuildVideosTaskBody_AspectRatioAndSoundDualWrite(t *testing.T) { + req := &videosCreateRequest{ + Model: "kling-v2-master", + Prompt: "a cat", + AspectRatio: "9:16", + Sound: "on", + } + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // minimax 的 resolveRatio 顶层 ratio 优先,必须写 + if parsed["ratio"] != "9:16" { + t.Fatalf("expected top-level ratio, got %v", parsed["ratio"]) + } + + meta := parsed["metadata"].(map[string]any) + if meta["ratio"] != "9:16" || meta["aspect_ratio"] != "9:16" { + t.Fatalf("expected metadata ratio + aspect_ratio, got %v", meta) + } + if meta["sound"] != "on" { + t.Fatalf("expected metadata.sound on, got %v", meta["sound"]) + } + if meta["generate_audio"] != true { + t.Fatalf("expected metadata.generate_audio true, got %v", meta["generate_audio"]) + } +} + +// TestBuildVideosTaskBody_SoundOff 关闭音频时 generate_audio 必须为 false(而非缺省) +func TestBuildVideosTaskBody_SoundOff(t *testing.T) { + body, _ := BuildVideosTaskBody(&videosCreateRequest{Model: "m", Prompt: "p", Sound: "off"}) + var parsed map[string]any + json.Unmarshal(body, &parsed) + meta := parsed["metadata"].(map[string]any) + if meta["generate_audio"] != false { + t.Fatalf("expected generate_audio false, got %v", meta["generate_audio"]) + } + if _, ok := parsed["ratio"]; ok { + t.Fatal("expected no ratio without aspect_ratio") + } +} + +// TestNewVideosRequestEcho_ExtendedFields 回显需带上扩展字段(submit/retrieve 回填与排障用) +func TestNewVideosRequestEcho_ExtendedFields(t *testing.T) { + echo := NewVideosRequestEcho(&videosCreateRequest{ + Prompt: "p", Seconds: "8", Size: "768P", AspectRatio: "16:9", Sound: "off", + }) + b, err := json.Marshal(echo) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + json.Unmarshal(b, &got) + if got["aspect_ratio"] != "16:9" || got["sound"] != "off" { + t.Fatalf("unexpected echo: %s", b) + } +} diff --git a/relay/handler/videos_pipeline_test.go b/relay/handler/videos_pipeline_test.go new file mode 100644 index 0000000..998356a --- /dev/null +++ b/relay/handler/videos_pipeline_test.go @@ -0,0 +1,334 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shopspring/decimal" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 端到端集成测试:OpenAI Videos 协议(multipart)→ 转换 → 任务提交管线(kling 适配器 + mock 上游) +// → video_ 前缀 ID + 官方 Video 对象响应 + 请求回显落 PrivateData + 上游收到映射后的 kling 参数。 + +// stubVideosBillingProvider 提交路径的 TaskBillingProvider 桩(计费全放行,金额固定) +type stubVideosBillingProvider struct { + common.TaskBillingProvider +} + +func (s *stubVideosBillingProvider) EstimateTaskCost(context.Context, int64, string, map[string]any, []byte) (decimal.Decimal, error) { + return decimal.NewFromFloat(0.1), nil +} + +func (s *stubVideosBillingProvider) PreDeductTask(_ context.Context, _ int64, _ string, cost decimal.Decimal, _ string) (decimal.Decimal, error) { + return cost, nil +} + +func (s *stubVideosBillingProvider) CheckRateLimit(context.Context, int64, int64, int64, int) (bool, string, int, int, int64) { + return true, "", 0, 0, 0 +} + +func (s *stubVideosBillingProvider) AcquireApiKeyConcurrent(context.Context, int64, int) bool { + return true +} +func (s *stubVideosBillingProvider) ReleaseApiKeyConcurrent(context.Context, int64) {} +func (s *stubVideosBillingProvider) CheckApiKeyQuota(context.Context, int64, decimal.Decimal) error { + return nil +} + +func (s *stubVideosBillingProvider) AdjustTaskBilling(_ context.Context, _ int64, _ string, _, newCost decimal.Decimal) (decimal.Decimal, error) { + return newCost, nil +} + +// stubVideosSubmitProvider 提交路径的 TaskDataProvider 桩(记录创建的任务) +type stubVideosSubmitProvider struct { + common.TaskDataProvider + + created *common.AsyncTask +} + +func (s *stubVideosSubmitProvider) CreateTask(_ context.Context, task *common.AsyncTask) error { + s.created = task + return nil +} + +func (s *stubVideosSubmitProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.created != nil && s.created.PublicTaskID == publicTaskID { + return s.created, nil + } + return nil, nil +} + +func TestHandleTaskSubmit_OpenAIVideosProtocol_FullPipeline(t *testing.T) { + // 1. mock kling 上游:记录收到的请求体,返回标准提交成功响应 + var upstreamReceived map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v1/videos/text2video") { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &upstreamReceived); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "message": "success", + "data": map[string]any{"task_id": "kling-up-1", "task_status": "submitted"}, + }) + })) + defer upstream.Close() + + // 2. multipart 创建请求 → 归一 → 通用任务体(与胶水层 HandleVideoCreate 相同链路) + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "kling-v2-master", + "prompt": "a cat surfing", + "seconds": "8", + "size": "1280x720", + }, "", "", nil) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + // 3. 跑完整提交管线(kling 适配器 + mock 上游 + 计费/存储桩) + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_videos_test", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + channelMeta := &common.ChannelMeta{ + ChannelID: 11, + ChannelType: int(constant.ProviderKling), + ChannelName: "mock-kling", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "kling-v2-master", + } + billing := &stubVideosBillingProvider{} + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, billing, channelMeta) + + // 4. 断言客户端响应:官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" || resp["progress"] != float64(0) { + t.Fatalf("unexpected video object: %s", rec.Body.String()) + } + videoID, _ := resp["id"].(string) + if !strings.HasPrefix(videoID, "video_") { + t.Fatalf("expected video_ prefixed id, got %q", videoID) + } + if resp["model"] != "kling-v2-master" || resp["seconds"] != "8" || resp["size"] != "1280x720" { + t.Fatalf("expected echo fields in submit response: %s", rec.Body.String()) + } + if prompt, _ := resp["prompt"].(string); prompt != "a cat surfing" { + t.Fatalf("expected prompt echo: %s", rec.Body.String()) + } + if rc.TaskID != videoID { + t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, videoID) + } + + // 5. 断言落库任务:video_ ID、kling 平台、PrivateData 含 request_echo + if data.created == nil { + t.Fatal("expected task record created") + } + if data.created.PublicTaskID != videoID { + t.Fatalf("created task id=%q", data.created.PublicTaskID) + } + if data.created.Platform != "kling" { + t.Fatalf("expected platform kling, got %q", data.created.Platform) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *videosRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "kling-up-1" { + t.Fatalf("expected upstream task id captured, got %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Seconds != "8" || pd.RequestEcho.Size != "1280x720" { + t.Fatalf("expected request echo persisted: %+v", pd.RequestEcho) + } + + // 6. 断言上游收到映射后的 kling 参数(seconds→duration;size 不换算,kling 不消费该键) + if upstreamReceived["model_name"] != "kling-v2-master" { + t.Fatalf("upstream model_name: %v", upstreamReceived["model_name"]) + } + if upstreamReceived["duration"] != "8" { + t.Fatalf("upstream duration: %v", upstreamReceived["duration"]) + } + if upstreamReceived["prompt"] != "a cat surfing" { + t.Fatalf("upstream prompt: %v", upstreamReceived["prompt"]) + } +} + +func TestHandleTaskSubmit_OpenAIVideosProtocol_Seedance(t *testing.T) { + // mock 火山引擎(seedance)上游:提交端点返回任务 ID + var upstreamReceived map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/v3/contents/generations/tasks") { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &upstreamReceived); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-seedance-1"}) + })) + defer upstream.Close() + + // multipart 创建请求:图生视频(input_reference 文件)+ 高清横屏档 + pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "doubao-seedance-2-0-260128", + "prompt": "小猫对着镜头打哈欠", + "seconds": "12", + "size": "1792x1024", + }, "input_reference", "ref.png", pngBytes) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_seedance_test", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + channelMeta := &common.ChannelMeta{ + ChannelID: 12, + ChannelType: int(constant.ProviderVolcengine), + ChannelName: "mock-volcengine", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "doubao-seedance-2-0-260128", + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta) + + // 客户端响应:官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" { + t.Fatalf("unexpected video object: %s", rec.Body.String()) + } + if resp["seconds"] != "12" || resp["size"] != "1792x1024" { + t.Fatalf("expected echo fields: %s", rec.Body.String()) + } + if data.created == nil || data.created.Platform != "volcengine" { + t.Fatalf("expected volcengine task record, got %+v", data.created) + } + + // 上游收到的 seedance 参数:content 文本+首帧图、duration 对上; + // size 不再换算派生(resolution/ratio 由 volcengine 上游默认值兜底) + if upstreamReceived["model"] != "doubao-seedance-2-0-260128" { + t.Fatalf("upstream model: %v", upstreamReceived["model"]) + } + if upstreamReceived["duration"] != float64(12) { + t.Fatalf("upstream duration: %v (%T)", upstreamReceived["duration"], upstreamReceived["duration"]) + } + if _, has := upstreamReceived["resolution"]; has { + t.Fatalf("size must not derive resolution for volcengine: %v", upstreamReceived["resolution"]) + } + if _, has := upstreamReceived["ratio"]; has { + t.Fatalf("size must not derive ratio for volcengine: %v", upstreamReceived["ratio"]) + } + content, ok := upstreamReceived["content"].([]any) + if !ok || len(content) != 2 { + t.Fatalf("expected text + first_frame image content items, got %v", upstreamReceived["content"]) + } + textItem, _ := content[0].(map[string]any) + if textItem["type"] != "text" || textItem["text"] != "小猫对着镜头打哈欠" { + t.Fatalf("unexpected text item: %v", content[0]) + } + imgItem, _ := content[1].(map[string]any) + if imgItem["type"] != "image_url" { + t.Fatalf("expected image_url item, got %v", content[1]) + } + imgURL, _ := imgItem["image_url"].(map[string]any) + urlStr, _ := imgURL["url"].(string) + if !strings.HasPrefix(urlStr, "data:image/png;base64,") { + t.Fatalf("expected base64 data url as first frame, got %.40q", urlStr) + } +} + +func TestHandleTaskSubmit_LegacyProtocolUnchanged(t *testing.T) { + // 回归:Protocol 为空(legacy)时提交响应保持 {id: task_*, status: SUBMITTED} 旧格式 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "message": "success", + "data": map[string]any{"task_id": "kling-up-2", "task_status": "submitted"}, + }) + })) + defer upstream.Close() + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_legacy_test", + Writer: rec, + } + channelMeta := &common.ChannelMeta{ + ChannelID: 11, + ChannelType: int(constant.ProviderKling), + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "kling-v2-master", + } + body := []byte(`{"model":"kling-v2-master","prompt":"legacy","metadata":{"duration":5}}`) + HandleTaskSubmit(context.Background(), body, "/v1/video/generations", http.Header{}, rc, &stubVideosSubmitProvider{}, &stubVideosBillingProvider{}, channelMeta) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["status"] != "SUBMITTED" { + t.Fatalf("legacy submit must keep SUBMITTED status: %s", rec.Body.String()) + } + if id, _ := resp["id"].(string); !strings.HasPrefix(id, "task_") { + t.Fatalf("legacy submit must keep task_ prefix: %s", rec.Body.String()) + } + if _, hasObject := resp["object"]; hasObject { + t.Fatalf("legacy submit must not carry video object field: %s", rec.Body.String()) + } +} diff --git a/relay/handler/videos_test.go b/relay/handler/videos_test.go new file mode 100644 index 0000000..abe3269 --- /dev/null +++ b/relay/handler/videos_test.go @@ -0,0 +1,336 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" +) + +// stubVideosTaskProvider 查询/删除路径的 TaskDataProvider 桩: +// 嵌入接口零值,未覆盖的方法被调用时 panic(测试应只触达覆盖的方法)。 +type stubVideosTaskProvider struct { + common.TaskDataProvider + + task *common.AsyncTask + fetchErr error + deleteCalls int + deleteErr error +} + +func (s *stubVideosTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.fetchErr != nil { + return nil, s.fetchErr + } + if s.task != nil && s.task.PublicTaskID == publicTaskID { + return s.task, nil + } + return nil, nil +} + +func (s *stubVideosTaskProvider) SoftDeleteTask(_ context.Context, task *common.AsyncTask) error { + s.deleteCalls++ + if s.deleteErr != nil { + return s.deleteErr + } + task.Deleted = true + return nil +} + +func newVideosTestTask() *common.AsyncTask { + finish := time.Unix(1760000432, 0) + privateData, _ := json.Marshal(map[string]any{ + "upstream_task_id": "up-1", + "task_type": "kling", + "request_echo": map[string]any{"prompt": "a cat", "seconds": "8", "size": "1280x720"}, + }) + return &common.AsyncTask{ + ID: 1, + PublicTaskID: "video_abc", + Status: "SUCCESS", + Progress: "100%", + ModelName: "kling-v2-master", + ResultURL: "https://cdn.example.com/v.mp4", + BillingSettled: true, + FinishTime: &finish, + CreatedAt: time.Unix(1760000000, 0), + PrivateData: privateData, + TenantID: 7, + UserID: 9, + } +} + +func videosTestRC(w http.ResponseWriter) *TaskRelayContext { + return &TaskRelayContext{TenantID: 7, UserID: 9, Writer: w} +} + +func decodeVideosJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode json: %v, body=%s", err, rec.Body.String()) + } + return m +} + +func TestVideosStatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "queued", + "SUBMITTED": "queued", + "QUEUED": "queued", + "IN_PROGRESS": "in_progress", + "SUCCESS": "completed", + "FAILURE": "failed", + "WEIRD": "queued", + } + for in, want := range cases { + if got := videosStatusFromTask(in); got != want { + t.Errorf("videosStatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestVideosProgressFromTask(t *testing.T) { + makeTask := func(status, progress string) *common.AsyncTask { + return &common.AsyncTask{Status: status, Progress: progress} + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "42%")); got != 42 { + t.Errorf("42%% → %d", got) + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "150%")); got != 100 { + t.Errorf("150%% should clamp to 100, got %d", got) + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "")); got != 50 { + t.Errorf("fallback in_progress → 50, got %d", got) + } + if got := videosProgressFromTask(makeTask("SUCCESS", "")); got != 100 { + t.Errorf("fallback success → 100, got %d", got) + } +} + +func TestBuildVideoObject_SuccessWithEcho(t *testing.T) { + obj := buildVideoObject(newVideosTestTask()) + if obj.ID != "video_abc" || obj.Object != "video" || obj.Status != "completed" || obj.Progress != 100 { + t.Fatalf("unexpected object: %+v", obj) + } + if obj.Model != "kling-v2-master" || obj.Prompt == nil || *obj.Prompt != "a cat" { + t.Fatalf("unexpected model/prompt: %+v", obj) + } + if obj.Seconds != "8" || obj.Size != "1280x720" { + t.Fatalf("expected echo seconds/size, got %q/%q", obj.Seconds, obj.Size) + } + if obj.CompletedAt == nil || *obj.CompletedAt != 1760000432 { + t.Fatalf("unexpected completed_at: %v", obj.CompletedAt) + } + if obj.Error != nil || obj.RemixedFromVideoID != nil || obj.ExpiresAt != nil { + t.Fatalf("nullables must be nil: %+v", obj) + } +} + +func TestBuildVideoObject_Failure(t *testing.T) { + task := newVideosTestTask() + task.Status = "FAILURE" + task.Progress = "30%" + task.FailReason = "upstream exploded" + obj := buildVideoObject(task) + if obj.Status != "failed" || obj.Error == nil { + t.Fatalf("expected failed with error: %+v", obj) + } + if obj.Error.Code != "video_generation_failed" || obj.Error.Message != "upstream exploded" { + t.Fatalf("unexpected error object: %+v", obj.Error) + } +} + +func TestHandleVideosRetrieve_FoundAndPollHeader(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + task.Progress = "42%" + task.FinishTime = nil + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("openai-poll-after-ms"); got != videosPollAfterMs { + t.Fatalf("expected poll header %q, got %q", videosPollAfterMs, got) + } + m := decodeVideosJSON(t, rec) + if m["status"] != "in_progress" || m["progress"] != float64(42) || m["object"] != "video" { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestHandleVideosRetrieve_TerminalNoPollHeader(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider) + if got := rec.Header().Get("openai-poll-after-ms"); got != "" { + t.Fatalf("terminal task must not carry poll header, got %q", got) + } +} + +func TestHandleVideosRetrieve_NotFound(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{} + HandleVideosRetrieve(context.Background(), "video_missing", videosTestRC(rec), provider) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +func TestHandleVideosDelete_HappyPath(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if provider.deleteCalls != 1 { + t.Fatalf("expected 1 soft delete call, got %d", provider.deleteCalls) + } + m := decodeVideosJSON(t, rec) + if m["object"] != "video.deleted" || m["deleted"] != true || m["id"] != "video_abc" { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_NonTerminalRejected(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + if provider.deleteCalls != 0 { + t.Fatal("must not delete non-terminal task") + } + if !strings.Contains(rec.Body.String(), "video_not_deletable") { + t.Fatalf("expected video_not_deletable code, body=%s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_UnsettledRejected(t *testing.T) { + task := newVideosTestTask() + task.BillingSettled = false + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusBadRequest || provider.deleteCalls != 0 { + t.Fatalf("expected 400 without delete, got %d calls=%d", rec.Code, provider.deleteCalls) + } + if !strings.Contains(rec.Body.String(), "billing_pending") { + t.Fatalf("expected billing_pending code, body=%s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_NotFound(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{} + HandleVideosDelete(context.Background(), "video_gone", videosTestRC(rec), provider) + if rec.Code != http.StatusNotFound || provider.deleteCalls != 0 { + t.Fatalf("expected 404 without delete, got %d", rec.Code) + } +} + +func TestHandleVideosContent_StreamsBytes(t *testing.T) { + content := []byte("FAKE-MP4-BYTES") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Write(content) + })) + defer upstream.Close() + + task := newVideosTestTask() + task.ResultURL = upstream.URL + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "video", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "video/mp4" { + t.Fatalf("expected video/mp4 passthrough, got %q", ct) + } + if rec.Body.String() != string(content) { + t.Fatalf("body mismatch: %q", rec.Body.String()) + } +} + +func TestHandleVideosContent_VariantUnsupported(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosContent(context.Background(), "video_abc", "thumbnail", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestHandleVideosContent_NotCompleted(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "video_not_ready") { + t.Fatalf("expected 400 video_not_ready, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleVideosContent_NoResultURL(t *testing.T) { + task := newVideosTestTask() + task.ResultURL = "" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "content_not_available") { + t.Fatalf("expected 400 content_not_available, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleVideosContent_UpstreamFailure(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer upstream.Close() + + task := newVideosTestTask() + task.ResultURL = upstream.URL + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", rec.Code) + } +} + +func TestWriteVideosSubmitResponse(t *testing.T) { + rec := httptest.NewRecorder() + writeVideosSubmitResponse(rec, "video_new", "kling-v2-master", time.Unix(1760000000, 0), videosRequestEcho{Prompt: "p", Seconds: "8", Size: "720x1280"}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + m := decodeVideosJSON(t, rec) + if m["id"] != "video_new" || m["object"] != "video" || m["status"] != "queued" || m["progress"] != float64(0) { + t.Fatalf("unexpected submit response: %s", rec.Body.String()) + } + if m["seconds"] != "8" || m["size"] != "720x1280" { + t.Fatalf("expected echo fields: %s", rec.Body.String()) + } +} 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/stream.go b/relay/helper/stream.go index 308efc6..33c259f 100644 --- a/relay/helper/stream.go +++ b/relay/helper/stream.go @@ -1,6 +1,9 @@ package helper import ( + "bytes" + "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -61,10 +64,18 @@ func WriteSSEEvent(w http.ResponseWriter, event string, data string) error { return nil } -// WriteSSEPing 写入 SSE 保活注释(`: PING\n\n`) -// SSE 规范中以 `:` 开头的行是注释,客户端会忽略但会保持连接活跃 +// ssePingFrame 保活注释帧。SSE 规范中以 `:` 开头的行是注释,客户端会忽略但会保持连接活跃。 +const ssePingFrame = ": PING\n\n" + +// WriteSSEPing 写入 SSE 保活注释(`: PING\n\n`)。 +// w 为 SafeWriter 时走 WritePing:在同一把锁内检查「调用方是否正处于分多次写同一帧的中途」, +// 是则跳过本次 ping —— ping 自带的空行会把那一帧劈成两个事件(详见 SSEFrameWriter 说明)。 func WriteSSEPing(w http.ResponseWriter) error { - _, err := fmt.Fprintf(w, ": PING\n\n") + if sw, ok := w.(*SafeWriter); ok { + _, err := sw.WritePing() + return err + } + _, err := w.Write([]byte(ssePingFrame)) if err != nil { return err } @@ -82,6 +93,96 @@ func WriteJSON(w http.ResponseWriter, statusCode int, data any) error { return err } +const ( + // sseEventBufInitCap 池中缓冲的初始容量,覆盖绝大多数流式事件,避免首次写入扩容 + sseEventBufInitCap = 2 * 1024 + // sseEventBufKeepCap 归还池的缓冲容量上限,超过则丢弃,避免个别超大事件把容量永久钉在池里 + sseEventBufKeepCap = 64 * 1024 +) + +// ErrSSEPayloadMarshal payload 序列化失败,事件未写出(一个字节都没有出网)。 +// 调用方据此区分两类失败: +// - 序列化失败 → 事件被跳过,应记日志:写出 data 为空的畸形帧会让客户端 JSON.parse("") +// 抛错并中止整条请求,而网关侧只会看到 ctx 取消、记成 client_gone; +// - 写客户端失败 → 客户端已不可达,由主循环的中断路径统一处理,不必逐事件刷日志。 +var ErrSSEPayloadMarshal = errors.New("sse payload marshal failed") + +// sseEventBuffer 复用的帧组装缓冲。encoder 在构造时绑定到 buf,二者一起池化, +// 使「序列化 + 拼帧」全程零分配。 +// +// 复用 json.Encoder 的前提:Encoder 只在底层 writer 写失败时置位其内部粘滞错误, +// 而 bytes.Buffer 的 Write 永不返回错误,故池中的 encoder 不会被毒化。 +type sseEventBuffer struct { + buf *bytes.Buffer + enc *json.Encoder +} + +var sseEventBufPool = sync.Pool{ + New: func() any { + buf := bytes.NewBuffer(make([]byte, 0, sseEventBufInitCap)) + return &sseEventBuffer{buf: buf, enc: json.NewEncoder(buf)} + }, +} + +func getSSEEventBuffer() *sseEventBuffer { + eb := sseEventBufPool.Get().(*sseEventBuffer) + eb.buf.Reset() + return eb +} + +func putSSEEventBuffer(eb *sseEventBuffer) { + if eb.buf.Cap() > sseEventBufKeepCap { + return + } + sseEventBufPool.Put(eb) +} + +// writeSSEFrame 把 eb 中已拼好的帧一次性写出并 Flush。 +func writeSSEFrame(w http.ResponseWriter, eb *sseEventBuffer) error { + if _, err := w.Write(eb.buf.Bytes()); err != nil { + return err + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return nil +} + +// WriteSSEEventJSON 序列化 payload 并作为一个完整 SSE 事件帧一次性写出(`event: X\ndata: {...}\n\n`)。 +// 相比「json.Marshal → string() → Fprintf」少若干次分配,且整帧单次 Write —— 天然满足帧原子, +// 并发的保活 ping 无法把空行插进帧中间。 +// 序列化失败时不写出任何字节,返回包装 ErrSSEPayloadMarshal 的错误。 +func WriteSSEEventJSON(w http.ResponseWriter, event string, payload any) error { + eb := getSSEEventBuffer() + defer putSSEEventBuffer(eb) + + eb.buf.WriteString("event: ") + eb.buf.WriteString(event) + eb.buf.WriteString("\ndata: ") + // Encode 在 JSON 之后自带换行,正好充当 data 行的行尾;序列化失败时不会向 buf 写入任何内容 + if err := eb.enc.Encode(payload); err != nil { + return fmt.Errorf("%w: %v", ErrSSEPayloadMarshal, err) + } + eb.buf.WriteByte('\n') // 帧分隔空行 + + return writeSSEFrame(w, eb) +} + +// WriteSSEDataJSON 序列化 payload 并作为一个只有 data 行的完整 SSE 帧一次性写出 +// (`data: {...}\n\n`)。语义与 WriteSSEEventJSON 相同,用于 OpenAI 风格的无 event 名流式块。 +func WriteSSEDataJSON(w http.ResponseWriter, payload any) error { + eb := getSSEEventBuffer() + defer putSSEEventBuffer(eb) + + eb.buf.WriteString("data: ") + if err := eb.enc.Encode(payload); err != nil { + return fmt.Errorf("%w: %v", ErrSSEPayloadMarshal, err) + } + eb.buf.WriteByte('\n') + + return writeSSEFrame(w, eb) +} + // ExtractSSEData 从 SSE 行中提取 data 字段内容 // 兼容 "data: content"(带空格)和 "data:content"(不带空格)两种格式 // 如果不是 data 行返回 ("", false) @@ -165,12 +266,15 @@ func ApplyInterruptedUsageFallback(info *common.RelayInfo, usage *common.Usage, // SafeWriter 包装 http.ResponseWriter,用互斥锁串行化 Write/WriteHeader/Flush, // 使保活 ping goroutine 与主循环可以安全地并发写同一个 ResponseWriter。 // -// 注意:fmt.Fprintf 对单个 SSE 帧只产生一次 Write 调用,因此每个 SSE 帧在锁内是原子写入的; -// Flush 单独加锁,与其他写入交错也不会破坏帧的字节完整性。 +// 注意:本类型只保证**单次 Write 调用**原子,不保证 SSE 帧原子。 +// 调用方一次 Write 写完整帧时(如 WriteSSEData/WriteSSEEvent 的单次 Fprintf),帧即原子; +// 而按行转发上游 SSE 的调用方必须改用 SSEFrameWriter 攒齐整帧再写,否则 ping 的空行 +// 会插进帧中间,让客户端派发出 data 为空的事件(详见 SSEFrameWriter 的说明)。 type SafeWriter struct { w http.ResponseWriter mu sync.Mutex headersSet bool // SSE 头幂等性标志(防止 SetEventStreamHeaders 重复调用导致 panic) + midFrame bool // 调用方正在分多次写出同一帧(SSEFrameWriter 超大帧降级),期间抑制 ping } // NewSafeWriter 创建一个并发安全的 ResponseWriter 包装器。 @@ -206,11 +310,144 @@ func (s *SafeWriter) Flush() { } } +// SetMidFrame 标记/清除「当前有一帧正在分多次写出」。置位期间 WritePing 跳过写入, +// 避免 ping 自带的空行落在帧中间。仅 SSEFrameWriter 的超大帧降级路径会用到。 +func (s *SafeWriter) SetMidFrame(v bool) { + s.mu.Lock() + s.midFrame = v + s.mu.Unlock() +} + +// WritePing 在持锁状态下写出保活注释帧,返回是否实际写出。 +// 检查与写入必须在同一临界区内完成,否则「检查通过 → 调用方开始写半帧 → ping 写入」 +// 的交错依然会劈开帧。 +func (s *SafeWriter) WritePing() (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.midFrame { + return false, nil + } + if _, err := s.w.Write([]byte(ssePingFrame)); err != nil { + return false, err + } + if f, ok := s.w.(http.Flusher); ok { + f.Flush() + } + return true, nil +} + var ( _ http.ResponseWriter = (*SafeWriter)(nil) _ http.Flusher = (*SafeWriter)(nil) ) +const ( + // sseFrameMaxBuf 单帧缓冲上限:超过则先把已缓冲内容写出(降级为逐行写), + // 防止上游异常(始终不发空行)把整条流攒进内存。正常 SSE 帧远小于此。 + sseFrameMaxBuf = 1 << 20 // 1 MiB + // sseFrameKeepBuf 帧写出后保留复用的缓冲上限,超过则释放,避免个别大帧长期占用内存 + sseFrameKeepBuf = 64 * 1024 +) + +// IsSSEFrameBoundary 判断一行是否为 SSE 帧分隔空行(兼容 LF / CRLF 两种行尾)。 +func IsSSEFrameBoundary(line string) bool { + return line == "\n" || line == "\r\n" +} + +// SSEFrameWriter 为「逐行转发上游 SSE」的场景提供帧级原子写入。 +// +// SSE 以空行分帧,客户端读到空行即派发事件。若转发方按行写,而保活 ping goroutine +// 又并发写同一个 writer,ping 自带的空行就可能插进一帧中间,把只写了 `event: X` 的 +// 半帧提前派发出去 —— 客户端拿到 data 为空的事件,对其做 JSON.parse 直接抛错 +// (如 Claude Code 的 "JSON Parse error: Unexpected EOF")并中止请求,网关侧随后 +// 只观察到 ctx 取消,被记成 client_gone,真实成因被掩盖。 +// SafeWriter 只保证单次 Write 原子,保证不了整帧原子,故需本类型按帧攒齐再写。 +// +// 用法:逐行 WriteLine(遇空行自动整帧写出并 Flush);上游 EOF 时 FlushPartial +// 冲刷无空行结尾的残留;中断或上游读错时 Discard 丢弃半帧(半帧尚未出网,丢弃即可)。 +// 单帧超过 sseFrameMaxBuf 时降级为分次写出,此期间通过 SafeWriter.SetMidFrame 抑制 ping, +// 保证降级路径同样不会被空行劈开。 +// 本类型非并发安全,只应由转发主循环单协程使用,底层 w 需为 SafeWriter。 +type SSEFrameWriter struct { + w http.ResponseWriter + buf []byte +} + +// NewSSEFrameWriter 创建帧级原子写入器,w 应为并发安全的 writer(如 SafeWriter)。 +func NewSSEFrameWriter(w http.ResponseWriter) *SSEFrameWriter { + return &SSEFrameWriter{w: w} +} + +// WriteLine 追加一行(含行尾换行符)。该行为帧分隔空行时整帧写出; +// 缓冲超过上限时降级为分次写出,并在帧写完前抑制保活 ping。 +func (f *SSEFrameWriter) WriteLine(line string) error { + f.buf = append(f.buf, line...) + if IsSSEFrameBoundary(line) { + return f.flush(true) + } + if len(f.buf) >= sseFrameMaxBuf { + // 超大帧降级:写出已缓冲部分,但这一批字节不含帧尾, + // 标记 midFrame 让 ping 让路,直到该帧的空行到达 + return f.flush(false) + } + return nil +} + +// FlushPartial 冲刷未以空行结尾的残留内容(上游 EOF 时调用,避免丢掉最后一帧)。 +func (f *SSEFrameWriter) FlushPartial() error { + return f.flush(true) +} + +// Discard 丢弃尚未写出的半帧内容。 +func (f *SSEFrameWriter) Discard() { + f.buf = f.buf[:0] + f.setMidFrame(false) +} + +// Buffered 返回当前缓冲的字节数(尚未写给客户端的半帧)。 +func (f *SSEFrameWriter) Buffered() int { + return len(f.buf) +} + +// setMidFrame 把「帧写到一半」的状态透给底层 SafeWriter,由它在写 ping 时同锁判定。 +func (f *SSEFrameWriter) setMidFrame(v bool) { + if sw, ok := f.w.(*SafeWriter); ok { + sw.SetMidFrame(v) + } +} + +// flush 写出缓冲内容。frameComplete 表示这批字节写完后帧是否完整 +// (遇到分隔空行 / 上游 EOF 收尾为 true,超大帧降级为 false)。 +func (f *SSEFrameWriter) flush(frameComplete bool) error { + if len(f.buf) == 0 { + if frameComplete { + f.setMidFrame(false) + } + return nil + } + if !frameComplete { + // 必须在 Write 之前置位:否则 ping 可能挤在「写出半帧」与「置位」之间 + f.setMidFrame(true) + } + _, err := f.w.Write(f.buf) + if cap(f.buf) > sseFrameKeepBuf { + f.buf = nil + } else { + f.buf = f.buf[:0] + } + if frameComplete { + // 帧已完整出网,放行 ping(写失败也要清除,避免把标记永久留在 writer 上) + f.setMidFrame(false) + } + if err != nil { + return err + } + if fl, ok := f.w.(http.Flusher); ok { + fl.Flush() + } + return nil +} + // PingTicker 在后台定期发送 SSE 保活注释 // 调用方必须传入并发安全的 writer(如 SafeWriter),以避免与主循环并发写 ResponseWriter // 返回一个 stop 函数用于停止 goroutine diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 8a66be7..fdc092e 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -16,15 +16,14 @@ import ( ) const ( - defaultStreamTimeout = 300 * time.Second // 两个 chunk 之间的最大空闲间隔(非总超时) - defaultPingInterval = 15 * time.Second - dataChanSize = 10 - stopChanSize = 3 - shutdownWaitTimeout = 5 * time.Second - minLineLength = 6 - scannerInitialBuf = 64 * 1024 - scannerMaxBuf = 10 * 1024 * 1024 - resetChanSize = 8 + defaultPingInterval = 15 * time.Second + dataChanSize = 10 + stopChanSize = 3 + shutdownWaitTimeout = 5 * time.Second + minLineLength = 6 + scannerInitialBuf = 64 * 1024 + scannerMaxBuf = 10 * 1024 * 1024 + resetChanSize = 8 ) // DataHandlerFunc data 处理回调 @@ -246,15 +245,17 @@ shutdown: } } -// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔),不低于 defaultStreamTimeout。 +// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔)。 +// 优先级:渠道自定义(须大于全局值才生效)> 全局配置(系统设置 streaming_timeout_seconds)> 内置默认。 func getStreamTimeout(info *common.RelayInfo) time.Duration { + global := time.Duration(common.GlobalStreamIdleTimeoutSeconds()) * time.Second if info.ChannelMeta != nil && info.ChannelMeta.Settings.TimeoutSeconds > 0 { t := time.Duration(info.ChannelMeta.Settings.TimeoutSeconds) * time.Second - if t > defaultStreamTimeout { + if t > global { return t } } - return defaultStreamTimeout + return global } // safeSendBool 安全发送到 stopChan(recover 避免向已关闭 channel 写入) diff --git a/relay/helper/stream_test.go b/relay/helper/stream_test.go index ad8da37..de87706 100644 --- a/relay/helper/stream_test.go +++ b/relay/helper/stream_test.go @@ -1,10 +1,16 @@ package helper import ( + "encoding/json" + "errors" "net/http" "net/http/httptest" + "strconv" "strings" + "sync" "testing" + + "github.com/qianfree/team-api/relay/dto" ) // TestSetEventStreamHeaders_Idempotency 测试 SSE 头幂等性保护 @@ -192,7 +198,767 @@ func TestWriteSSEPing(t *testing.T) { } } -// TestExtractSSEData 测试 SSE 数据提取 +// sseEvent 一个被客户端派发出去的 SSE 事件 +type sseEvent struct { + event string + data string +} + +// decodeSSE 按 SSE 规范模拟客户端解码:`:` 开头为注释忽略,空行即派发当前事件。 +// 用于断言网关吐出的字节在客户端眼里是什么,而不是只比对原始字符串。 +func decodeSSE(raw string) []sseEvent { + var events []sseEvent + cur := sseEvent{} + var dataLines []string + dispatch := func() { + if cur.event == "" && len(dataLines) == 0 { + return + } + cur.data = strings.Join(dataLines, "\n") + events = append(events, cur) + cur = sseEvent{} + dataLines = nil + } + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSuffix(line, "\r") + switch { + case line == "": + dispatch() + case strings.HasPrefix(line, ":"): + // 注释(保活 ping),忽略 + case strings.HasPrefix(line, "event:"): + cur.event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + dispatch() + return events +} + +// assertNoEmptyDataEvent 断言没有「有事件名但 data 为空」的事件 —— 这正是客户端 +// 对空串做 JSON.parse 报 "JSON Parse error: Unexpected EOF" 并中止请求的成因。 +func assertNoEmptyDataEvent(t *testing.T, raw string) { + t.Helper() + for i, ev := range decodeSSE(raw) { + if ev.event != "" && ev.data == "" { + t.Errorf("事件 #%d (event=%q) 的 data 为空,客户端 JSON.parse 会失败;原始输出:\n%s", + i, ev.event, raw) + } + } +} + +// TestSafeWriter_LineByLineWriteIsSplitByPing 对照用例:逐行写 SSE 时, +// SafeWriter 挡不住保活 ping 把自带空行插进帧中间 —— 半帧被提前派发, +// 客户端拿到 data 为空的事件。这是 SSEFrameWriter 存在的理由,也证明 +// assertNoEmptyDataEvent 确实能抓到该缺陷。 +func TestSafeWriter_LineByLineWriteIsSplitByPing(t *testing.T) { + rec := httptest.NewRecorder() + sw := NewSafeWriter(rec) + + _, _ = sw.Write([]byte("event: content_block_delta\n")) + // 主循环阻塞在读上游下一行时,ping goroutine 抢到了写入 + _ = WriteSSEPing(sw) + _, _ = sw.Write([]byte(`data: {"type":"content_block_delta"}` + "\n")) + _, _ = sw.Write([]byte("\n")) + + events := decodeSSE(rec.Body.String()) + if len(events) == 0 || events[0].data != "" { + t.Fatalf("对照用例失效:期望逐行写会派发出空 data 事件,实际 events=%+v", events) + } +} + +// TestSSEFrameWriter_PingCannotSplitFrame 回归:改用 SSEFrameWriter 后, +// 帧攒齐才出网,ping 只能落在帧边界,客户端不会再收到空 data 事件。 +func TestSSEFrameWriter_PingCannotSplitFrame(t *testing.T) { + rec := httptest.NewRecorder() + sw := NewSafeWriter(rec) + frame := NewSSEFrameWriter(sw) + + if err := frame.WriteLine("event: content_block_delta\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + // 半帧必须还留在缓冲里,一个字节都不能出网 + if rec.Body.Len() != 0 { + t.Errorf("半帧提前出网: %q", rec.Body.String()) + } + if frame.Buffered() == 0 { + t.Error("Buffered() = 0, 期望半帧仍在缓冲中") + } + + // ping goroutine 此刻抢到写入 + if err := WriteSSEPing(sw); err != nil { + t.Fatalf("WriteSSEPing failed: %v", err) + } + + if err := frame.WriteLine(`data: {"type":"content_block_delta"}` + "\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + if err := frame.WriteLine("\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + + body := rec.Body.String() + assertNoEmptyDataEvent(t, body) + + events := decodeSSE(body) + if len(events) != 1 { + t.Fatalf("派发事件数 = %d, want 1; body=%q", len(events), body) + } + if events[0].event != "content_block_delta" || events[0].data != `{"type":"content_block_delta"}` { + t.Errorf("事件 = %+v, 与写入内容不符", events[0]) + } + // ping 必须整体落在帧之前,不能夹在帧内部 + if !strings.HasPrefix(body, ": PING\n\n") { + t.Errorf("ping 未落在帧边界: %q", body) + } +} + +// TestSSEFrameWriter_ConcurrentPingDuringFrames 并发压力下的帧完整性: +// 主循环持续逐行写帧,ping goroutine 同时高频写入,客户端解码后不得出现空 data 事件。 +func TestSSEFrameWriter_ConcurrentPingDuringFrames(t *testing.T) { + rec := httptest.NewRecorder() + sw := NewSafeWriter(rec) + frame := NewSSEFrameWriter(sw) + + stopPing := make(chan struct{}) + pingDone := make(chan struct{}) + go func() { + defer close(pingDone) + for { + select { + case <-stopPing: + return + default: + _ = WriteSSEPing(sw) + } + } + }() + + for i := 0; i < 500; i++ { + if err := frame.WriteLine("event: content_block_delta\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + if err := frame.WriteLine(`data: {"i":` + strconv.Itoa(i) + "}\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + if err := frame.WriteLine("\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + } + + close(stopPing) + <-pingDone + + body := rec.Body.String() + assertNoEmptyDataEvent(t, body) + + events := decodeSSE(body) + if len(events) != 500 { + t.Fatalf("派发事件数 = %d, want 500", len(events)) + } + for i, ev := range events { + if want := `{"i":` + strconv.Itoa(i) + "}"; ev.data != want { + t.Fatalf("事件 #%d data = %q, want %q(帧内容错位)", i, ev.data, want) + } + } +} + +// TestSSEFrameWriter_CRLFBoundary 上游用 CRLF 行尾时同样按帧冲刷。 +// 旧实现只认 len(line)==1 && line[0]=='\n',CRLF 上游会导致整条流从不 Flush。 +func TestSSEFrameWriter_CRLFBoundary(t *testing.T) { + rec := httptest.NewRecorder() + frame := NewSSEFrameWriter(NewSafeWriter(rec)) + + _ = frame.WriteLine("event: ping\r\n") + if rec.Body.Len() != 0 { + t.Errorf("半帧提前出网: %q", rec.Body.String()) + } + _ = frame.WriteLine("data: {}\r\n") + _ = frame.WriteLine("\r\n") + + if frame.Buffered() != 0 { + t.Errorf("CRLF 空行未被识别为帧边界,仍有 %d 字节滞留", frame.Buffered()) + } + if got := rec.Body.String(); got != "event: ping\r\ndata: {}\r\n\r\n" { + t.Errorf("Body = %q", got) + } +} + +// TestSSEFrameWriter_FlushPartial 上游未以空行结尾时,残留必须冲刷,不能丢帧。 +func TestSSEFrameWriter_FlushPartial(t *testing.T) { + rec := httptest.NewRecorder() + frame := NewSSEFrameWriter(NewSafeWriter(rec)) + + _ = frame.WriteLine("data: {\"last\":true}\n") + if rec.Body.Len() != 0 { + t.Fatalf("无空行结尾的帧不应提前出网: %q", rec.Body.String()) + } + if err := frame.FlushPartial(); err != nil { + t.Fatalf("FlushPartial failed: %v", err) + } + if got := rec.Body.String(); got != "data: {\"last\":true}\n" { + t.Errorf("Body = %q, 最后一帧丢失", got) + } + // 空缓冲重复调用应为 no-op + if err := frame.FlushPartial(); err != nil { + t.Errorf("空缓冲 FlushPartial 应为 no-op, got %v", err) + } +} + +// TestSSEFrameWriter_Discard 中断时丢弃半帧,不把不完整的帧写给客户端。 +func TestSSEFrameWriter_Discard(t *testing.T) { + rec := httptest.NewRecorder() + frame := NewSSEFrameWriter(NewSafeWriter(rec)) + + _ = frame.WriteLine("event: content_block_delta\n") + frame.Discard() + if frame.Buffered() != 0 { + t.Errorf("Discard 后 Buffered() = %d, want 0", frame.Buffered()) + } + if err := frame.FlushPartial(); err != nil { + t.Fatalf("FlushPartial failed: %v", err) + } + if rec.Body.Len() != 0 { + t.Errorf("被丢弃的半帧仍然出网: %q", rec.Body.String()) + } +} + +// TestSSEFrameWriter_MaxBufDegrades 上游异常(始终不发空行)时按上限降级冲刷, +// 不把整条流攒进内存。 +func TestSSEFrameWriter_MaxBufDegrades(t *testing.T) { + rec := httptest.NewRecorder() + frame := NewSSEFrameWriter(NewSafeWriter(rec)) + + line := "data: " + strings.Repeat("x", 64*1024) + "\n" + for i := 0; i < sseFrameMaxBuf/len(line)+2; i++ { + if err := frame.WriteLine(line); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + } + if frame.Buffered() >= sseFrameMaxBuf { + t.Errorf("Buffered() = %d, 超过上限仍未降级冲刷", frame.Buffered()) + } + if rec.Body.Len() == 0 { + t.Error("超过上限后仍未向客户端写出任何数据") + } +} + +// ===== 池化帧写入器 ===== + +// benchClaudeDelta 构造一个典型的 Claude 流式增量事件,作为基准与парity 测试的载荷 +func benchClaudeDelta() *dto.ClaudeResponse { + text := "这是一段典型长度的流式增量文本内容" + idx := 0 + return &dto.ClaudeResponse{ + Type: "content_block_delta", + Index: &idx, + Delta: &dto.ClaudeDelta{Type: "text_delta", Text: &text}, + } +} + +// TestWriteSSEEventJSON_ByteParityWithMarshalFprintf 池化路径的输出必须与 +// 「json.Marshal → string → WriteSSEEvent」逐字节一致(含 HTML 转义行为), +// 否则等于悄悄改了对外协议。 +func TestWriteSSEEventJSON_ByteParityWithMarshalFprintf(t *testing.T) { + payloads := []any{ + benchClaudeDelta(), + &dto.ClaudeResponse{Type: "message_stop"}, + // HTML 字符:json.Marshal 与 json.Encoder 默认都转义,此处锁定该行为 + map[string]any{"text": `ac&d`, "n": 1.5, "null": nil}, + map[string]any{"empty": ""}, + } + + for i, p := range payloads { + old := httptest.NewRecorder() + data, err := json.Marshal(p) + if err != nil { + t.Fatalf("payload #%d marshal failed: %v", i, err) + } + if err := WriteSSEEvent(old, "content_block_delta", string(data)); err != nil { + t.Fatalf("WriteSSEEvent failed: %v", err) + } + + pooled := httptest.NewRecorder() + if err := WriteSSEEventJSON(pooled, "content_block_delta", p); err != nil { + t.Fatalf("WriteSSEEventJSON failed: %v", err) + } + + if old.Body.String() != pooled.Body.String() { + t.Errorf("payload #%d 输出不一致:\nold: %q\npooled: %q", i, old.Body.String(), pooled.Body.String()) + } + } +} + +// TestWriteSSEDataJSON_ByteParity data-only 帧同样要求逐字节一致。 +func TestWriteSSEDataJSON_ByteParity(t *testing.T) { + p := benchClaudeDelta() + + old := httptest.NewRecorder() + data, _ := json.Marshal(p) + if err := WriteSSEData(old, string(data)); err != nil { + t.Fatalf("WriteSSEData failed: %v", err) + } + + pooled := httptest.NewRecorder() + if err := WriteSSEDataJSON(pooled, p); err != nil { + t.Fatalf("WriteSSEDataJSON failed: %v", err) + } + + if old.Body.String() != pooled.Body.String() { + t.Errorf("输出不一致:\nold: %q\npooled: %q", old.Body.String(), pooled.Body.String()) + } +} + +// TestWriteSSEEventJSON_MarshalFailureWritesNothing 序列化失败时必须一个字节都不出网, +// 且错误可被 errors.Is 识别 —— 半截帧或空 data 帧会让客户端整条请求失败。 +func TestWriteSSEEventJSON_MarshalFailureWritesNothing(t *testing.T) { + rec := httptest.NewRecorder() + + // chan 无法被 encoding/json 序列化 + err := WriteSSEEventJSON(rec, "content_block_delta", map[string]any{"bad": make(chan int)}) + if !errors.Is(err, ErrSSEPayloadMarshal) { + t.Fatalf("err = %v, want ErrSSEPayloadMarshal", err) + } + if rec.Body.Len() != 0 { + t.Errorf("序列化失败仍写出了 %q", rec.Body.String()) + } + + // 失败后缓冲归池,不得污染后续事件 + rec2 := httptest.NewRecorder() + if err := WriteSSEEventJSON(rec2, "content_block_delta", benchClaudeDelta()); err != nil { + t.Fatalf("后续写入失败: %v", err) + } + if !strings.HasPrefix(rec2.Body.String(), "event: content_block_delta\ndata: {") { + t.Errorf("池被上一次失败污染: %q", rec2.Body.String()) + } +} + +// TestWriteSSEEventJSON_FrameIsSingleWrite 整帧必须单次 Write 写出 —— +// 这是保活 ping 无法插进帧中间的前提。 +func TestWriteSSEEventJSON_FrameIsSingleWrite(t *testing.T) { + w := &countingWriter{} + if err := WriteSSEEventJSON(w, "content_block_delta", benchClaudeDelta()); err != nil { + t.Fatalf("WriteSSEEventJSON failed: %v", err) + } + if w.writes != 1 { + t.Errorf("Write 调用次数 = %d, want 1(多次写出会给 ping 留出插入窗口)", w.writes) + } +} + +// countingWriter 统计 Write 调用次数 +type countingWriter struct { + header http.Header + writes int +} + +func (c *countingWriter) Header() http.Header { + if c.header == nil { + c.header = http.Header{} + } + return c.header +} +func (c *countingWriter) WriteHeader(int) {} +func (c *countingWriter) Write(p []byte) (int, error) { + c.writes++ + return len(p), nil +} + +// discardWriter 丢弃写入内容的 ResponseWriter,让基准只测序列化与拼帧开销 +type discardWriter struct{ header http.Header } + +func (d *discardWriter) Header() http.Header { + if d.header == nil { + d.header = http.Header{} + } + return d.header +} +func (d *discardWriter) WriteHeader(int) {} +func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// BenchmarkSSEEvent_MarshalFprintf 旧路径:json.Marshal → string() → fmt.Fprintf +func BenchmarkSSEEvent_MarshalFprintf(b *testing.B) { + w := &discardWriter{} + payload := benchClaudeDelta() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + data, err := json.Marshal(payload) + if err != nil { + b.Fatal(err) + } + if err := WriteSSEEvent(w, "content_block_delta", string(data)); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkSSEEvent_PooledJSON 新路径:池化 buffer + 绑定的 json.Encoder,整帧单次 Write +func BenchmarkSSEEvent_PooledJSON(b *testing.B) { + w := &discardWriter{} + payload := benchClaudeDelta() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := WriteSSEEventJSON(w, "content_block_delta", payload); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkSSEEvent_MarshalFprintfParallel 并发下的旧路径(真实场景是多请求同时流式输出) +func BenchmarkSSEEvent_MarshalFprintfParallel(b *testing.B) { + payload := benchClaudeDelta() + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + w := &discardWriter{} + for pb.Next() { + data, err := json.Marshal(payload) + if err != nil { + b.Fatal(err) + } + if err := WriteSSEEvent(w, "content_block_delta", string(data)); err != nil { + b.Fatal(err) + } + } + }) +} + +// BenchmarkSSEEvent_PooledJSONParallel 并发下的新路径,验证 sync.Pool 的 per-P 缓存不成为瓶颈 +func BenchmarkSSEEvent_PooledJSONParallel(b *testing.B) { + payload := benchClaudeDelta() + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + w := &discardWriter{} + for pb.Next() { + if err := WriteSSEEventJSON(w, "content_block_delta", payload); err != nil { + b.Fatal(err) + } + } + }) +} + +// concurrentProbe 并发独立性探针载荷:stream/seq 定位来源,pad 让各流的帧长度不同, +// 一旦缓冲被跨流复用或 Reset 失效,长度或字段就会对不上。 +type concurrentProbe struct { + Stream int `json:"stream"` + Seq int `json:"seq"` + Pad string `json:"pad"` +} + +// TestWriteSSEEventJSON_ConcurrentIndependence 100 条流并发输出时,池化缓冲必须完全独占: +// 每条流的响应里只能有自己的事件,顺序、内容、长度都不得被别的流污染。 +// +// sync.Pool 的 Get 会把对象从池中取走,两个并发的 Get 不可能拿到同一个对象; +// 本用例锁死这一点,防止将来有人把 eb 或 eb.buf.Bytes() 的引用泄漏到 Put 之后。 +func TestWriteSSEEventJSON_ConcurrentIndependence(t *testing.T) { + const streams = 100 + const eventsPerStream = 50 + + var wg sync.WaitGroup + bodies := make([]string, streams) + errs := make([]error, streams) + + for s := 0; s < streams; s++ { + wg.Add(1) + go func(s int) { + defer wg.Done() + // 每条流一个独立 writer,对应真实场景里每个请求各自的 ResponseWriter + rec := httptest.NewRecorder() + w := NewSafeWriter(rec) + for e := 0; e < eventsPerStream; e++ { + payload := concurrentProbe{Stream: s, Seq: e, Pad: strings.Repeat("x", s%64)} + if err := WriteSSEEventJSON(w, "content_block_delta", payload); err != nil { + errs[s] = err + return + } + } + bodies[s] = rec.Body.String() + }(s) + } + wg.Wait() + + for s := 0; s < streams; s++ { + if errs[s] != nil { + t.Fatalf("stream %d 写入失败: %v", s, errs[s]) + } + events := decodeSSE(bodies[s]) + if len(events) != eventsPerStream { + t.Fatalf("stream %d 事件数 = %d, want %d", s, len(events), eventsPerStream) + } + for e, ev := range events { + var got concurrentProbe + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("stream %d 事件 #%d 不是合法 JSON(缓冲被跨流复用会产生这种拼接残片): %v\ndata=%q", + s, e, err, ev.data) + } + if got.Stream != s || got.Seq != e || len(got.Pad) != s%64 { + t.Fatalf("stream %d 事件 #%d 内容串流: got %+v, want stream=%d seq=%d padLen=%d", + s, e, got, s, e, s%64) + } + } + } +} + +// TestWriteSSEEventJSON_ConcurrentWithPing 并发输出叠加保活 ping: +// 帧在私有缓冲里拼好后单次写出,ping 只能落在帧边界,客户端不会拿到空 data 事件。 +func TestWriteSSEEventJSON_ConcurrentWithPing(t *testing.T) { + const streams = 32 + const eventsPerStream = 30 + + var wg sync.WaitGroup + bodies := make([]string, streams) + + for s := 0; s < streams; s++ { + wg.Add(1) + go func(s int) { + defer wg.Done() + rec := httptest.NewRecorder() + w := NewSafeWriter(rec) + + stopPing := make(chan struct{}) + pingDone := make(chan struct{}) + go func() { + defer close(pingDone) + for { + select { + case <-stopPing: + return + default: + _ = WriteSSEPing(w) + } + } + }() + + for e := 0; e < eventsPerStream; e++ { + _ = WriteSSEEventJSON(w, "content_block_delta", concurrentProbe{Stream: s, Seq: e}) + } + + close(stopPing) + <-pingDone + bodies[s] = rec.Body.String() + }(s) + } + wg.Wait() + + for s := 0; s < streams; s++ { + assertNoEmptyDataEvent(t, bodies[s]) + } +} + +// TestWriteSSEEventJSON_OversizedPayload 单个事件远超池缓冲的初始容量与回收阈值时, +// 必须照常完整写出(bytes.Buffer 自动扩容,不截断、不报错);超阈值的缓冲不回池, +// 但不影响后续事件的正确性。 +func TestWriteSSEEventJSON_OversizedPayload(t *testing.T) { + // 1 MiB 文本,远超 sseEventBufInitCap(2KiB) 与 sseEventBufKeepCap(64KiB) + huge := strings.Repeat("你好", 512*1024/6) + payload := concurrentProbe{Stream: 1, Seq: 2, Pad: huge} + + rec := httptest.NewRecorder() + if err := WriteSSEEventJSON(rec, "content_block_delta", payload); err != nil { + t.Fatalf("超大事件写出失败: %v", err) + } + + // 与旧路径逐字节一致(即:既没截断也没串位) + want := httptest.NewRecorder() + data, _ := json.Marshal(payload) + _ = WriteSSEEvent(want, "content_block_delta", string(data)) + if rec.Body.String() != want.Body.String() { + t.Fatalf("超大事件输出与 Marshal 路径不一致(len got=%d want=%d)", + rec.Body.Len(), want.Body.Len()) + } + + // 超阈值缓冲被丢弃后,后续事件仍然正确(下一次 Get 会新建) + next := httptest.NewRecorder() + if err := WriteSSEEventJSON(next, "content_block_delta", concurrentProbe{Stream: 3, Seq: 4}); err != nil { + t.Fatalf("后续事件写出失败: %v", err) + } + events := decodeSSE(next.Body.String()) + if len(events) != 1 { + t.Fatalf("后续事件数 = %d, want 1", len(events)) + } + var got concurrentProbe + if err := json.Unmarshal([]byte(events[0].data), &got); err != nil { + t.Fatalf("后续事件不是合法 JSON: %v", err) + } + if got.Stream != 3 || got.Seq != 4 || got.Pad != "" { + t.Errorf("后续事件被超大缓冲污染: %+v", got) + } +} + +// TestWriteSSEEventJSON_RealWorldFrameSizes 记录真实流式事件的实际帧长度, +// 用于校准 sseEventBufInitCap(初始容量应覆盖绝大多数事件,避免首次写入扩容)。 +func TestWriteSSEEventJSON_RealWorldFrameSizes(t *testing.T) { + text := "这是一段典型长度的流式增量文本内容" + idx := 0 + cases := []struct { + name string + event string + payload any + }{ + {"content_block_delta(文本)", "content_block_delta", &dto.ClaudeResponse{ + Type: "content_block_delta", Index: &idx, + Delta: &dto.ClaudeDelta{Type: "text_delta", Text: &text}, + }}, + {"message_start", "message_start", &dto.ClaudeResponse{ + Type: "message_start", + Message: &dto.ClaudeMessageInfo{ + ID: "msg_01ABCDEFGHIJKLMNOPQRSTUV", Type: "message", Role: "assistant", + Model: "claude-opus-4-8", Content: []dto.ClaudeContentBlock{}, + Usage: &dto.ClaudeUsage{InputTokens: 1234, OutputTokens: 0, CacheReadInputTokens: 5678}, + }, + }}, + {"message_delta(含 usage)", "message_delta", &dto.ClaudeResponse{ + Type: "message_delta", + Delta: &dto.ClaudeDelta{StopReason: &text}, + Usage: &dto.ClaudeUsage{InputTokens: 1234, OutputTokens: 567}, + }}, + {"message_stop", "message_stop", &dto.ClaudeResponse{Type: "message_stop"}}, + } + + for _, c := range cases { + rec := httptest.NewRecorder() + if err := WriteSSEEventJSON(rec, c.event, c.payload); err != nil { + t.Fatalf("%s 写出失败: %v", c.name, err) + } + n := rec.Body.Len() + t.Logf("%-28s 整帧 %4d 字节(初始容量 %d)", c.name, n, sseEventBufInitCap) + if n > sseEventBufInitCap { + t.Errorf("%s 帧长 %d 超过初始容量 %d,每个事件都会触发扩容,应上调 sseEventBufInitCap", + c.name, n, sseEventBufInitCap) + } + } +} + +// BenchmarkSSEData_PooledJSON_Pointer / _Value 验证「传指针而非结构体值」的理由: +// 结构体值传进 any 形参需要装箱,会额外堆分配一份拷贝;传指针则不会。 +// 迁移调用点时据此统一传 &chunk。 +func BenchmarkSSEData_PooledJSON_Pointer(b *testing.B) { + w := &discardWriter{} + payload := benchClaudeDelta() // 已是指针 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := WriteSSEDataJSON(w, payload); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSSEData_PooledJSON_Value(b *testing.B) { + w := &discardWriter{} + payload := *benchClaudeDelta() // 解引用成值 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := WriteSSEDataJSON(w, payload); err != nil { + b.Fatal(err) + } + } +} + +// TestSSEFrameWriter_MaxBufDegradeSuppressesPing 超大帧降级为分次写出时,写出的这批字节 +// 不含帧尾 —— 此刻放行 ping 依然会把帧劈成两个事件。降级期间必须抑制 ping,直到分隔空行到达。 +func TestSSEFrameWriter_MaxBufDegradeSuppressesPing(t *testing.T) { + rec := httptest.NewRecorder() + sw := NewSafeWriter(rec) + frame := NewSSEFrameWriter(sw) + + if err := frame.WriteLine("event: content_block_delta\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + // 帧尚未降级,ping 可以正常写(半帧还在缓冲里,没有出网风险) + if written, err := sw.WritePing(); err != nil || !written { + t.Fatalf("未降级时 ping 应正常写出: written=%v err=%v", written, err) + } + + line := "data: " + strings.Repeat("x", 64*1024) + "\n" + for i := 0; i < sseFrameMaxBuf/len(line)+2; i++ { + if err := frame.WriteLine(line); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + } + if rec.Body.Len() == 0 { + t.Fatal("超过上限后仍未降级写出,无法验证抑制逻辑") + } + + // 降级已发生:帧的一部分已经出网且没有帧尾,此刻 ping 必须让路 + written, err := sw.WritePing() + if err != nil { + t.Fatalf("WritePing failed: %v", err) + } + if written { + t.Error("超大帧降级写出期间 ping 未被抑制,空行会把帧劈成两个事件") + } + + // 帧结束后恢复:分隔空行到达,ping 重新放行 + if err := frame.WriteLine("\n"); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + written, err = sw.WritePing() + if err != nil { + t.Fatalf("WritePing failed: %v", err) + } + if !written { + t.Error("帧已完整写出,ping 应恢复") + } + + assertNoEmptyDataEvent(t, rec.Body.String()) +} + +// TestSSEFrameWriter_DiscardClearsPingSuppression 降级后走中断路径(Discard)时, +// 必须解除 ping 抑制,否则标记会永久留在 writer 上、后续保活全部失效。 +func TestSSEFrameWriter_DiscardClearsPingSuppression(t *testing.T) { + rec := httptest.NewRecorder() + sw := NewSafeWriter(rec) + frame := NewSSEFrameWriter(sw) + + line := "data: " + strings.Repeat("x", 64*1024) + "\n" + for i := 0; i < sseFrameMaxBuf/len(line)+2; i++ { + if err := frame.WriteLine(line); err != nil { + t.Fatalf("WriteLine failed: %v", err) + } + } + if written, _ := sw.WritePing(); written { + t.Fatal("前置条件不成立:降级期间 ping 应被抑制") + } + + frame.Discard() + if written, err := sw.WritePing(); err != nil || !written { + t.Errorf("Discard 后 ping 抑制未解除: written=%v err=%v", written, err) + } +} + +// TestSSEFrameWriter_WriteErrorPropagates 写客户端失败时错误必须上报, +// 供调用方走流中断结算(而非静默丢数据按成功结算)。 +func TestSSEFrameWriter_WriteErrorPropagates(t *testing.T) { + fw := &failingWriter{err: errors.New("connection reset by peer")} + frame := NewSSEFrameWriter(fw) + + if err := frame.WriteLine("event: x\n"); err != nil { + t.Fatalf("半帧不触发写入,不应报错: %v", err) + } + err := frame.WriteLine("\n") + if err == nil || !strings.Contains(err.Error(), "connection reset") { + t.Fatalf("WriteLine 帧结束时错误未上报: %v", err) + } + // 失败后缓冲已清空,不会重复写出 + if frame.Buffered() != 0 { + t.Errorf("写失败后 Buffered() = %d, want 0", frame.Buffered()) + } +} + +// failingWriter 写入必定失败的 ResponseWriter,用于验证错误传播 +type failingWriter struct { + err error +} + +func (f *failingWriter) Header() http.Header { return http.Header{} } +func (f *failingWriter) WriteHeader(int) {} +func (f *failingWriter) Write([]byte) (int, error) { return 0, f.err } + func TestExtractSSEData(t *testing.T) { tests := []struct { name string 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/helper/upstream_id.go b/relay/helper/upstream_id.go new file mode 100644 index 0000000..ff8242e --- /dev/null +++ b/relay/helper/upstream_id.go @@ -0,0 +1,30 @@ +package helper + +import "net/http" + +// upstreamRequestIDHeaders 上游请求 ID 候选响应头,按序取第一个非空。 +// http.Header.Get 按规范形式匹配,大小写不敏感("x-request-id" 与 "X-Request-Id" 等价)。 +var upstreamRequestIDHeaders = []string{ + "X-Oneapi-Request-Id", // new-api 系上游(本项目上游主流),每个响应必带 + "X-Request-Id", // OpenAI / 火山引擎等 + "Request-Id", // Anthropic + "X-Amzn-Requestid", // AWS BedRock +} + +// ExtractUpstreamRequestID 从上游响应头中提取请求 ID,无任何候选头时返回空串。 +// 用于排障时在上游日志中定位同一次调用;值截断到 128 字节对齐 bil_usage_logs +// 列宽,避免超长值导致用量落库失败。 +func ExtractUpstreamRequestID(h http.Header) string { + if h == nil { + return "" + } + for _, name := range upstreamRequestIDHeaders { + if v := h.Get(name); v != "" { + if len(v) > 128 { + return v[:128] + } + return v + } + } + return "" +} diff --git a/relay/helper/upstream_id_test.go b/relay/helper/upstream_id_test.go new file mode 100644 index 0000000..76f5818 --- /dev/null +++ b/relay/helper/upstream_id_test.go @@ -0,0 +1,48 @@ +package helper + +import ( + "net/http" + "testing" +) + +func TestExtractUpstreamRequestID(t *testing.T) { + cases := []struct { + name string + header http.Header + want string + }{ + {"new-api 头优先", http.Header{"X-Oneapi-Request-Id": {"oneapi-123"}}, "oneapi-123"}, + {"OpenAI 头", http.Header{"X-Request-Id": {"req_openai"}}, "req_openai"}, + {"小写形式同样命中", func() http.Header { + // 真实 HTTP 响应头由 net/http 规范化存储,用 Set 模拟(map 字面量 + // 直接塞小写键绕过规范化,不代表实际行为) + h := http.Header{} + h.Set("x-request-id", "req_lower") + return h + }(), "req_lower"}, + {"Anthropic 头", http.Header{"Request-Id": {"req_anthropic"}}, "req_anthropic"}, + {"AWS 头", http.Header{"X-Amzn-Requestid": {"amzn-1"}}, "amzn-1"}, + {"多候选时按序取第一个", http.Header{ + "X-Oneapi-Request-Id": {"oneapi-1"}, + "X-Request-Id": {"openai-1"}, + }, "oneapi-1"}, + {"仅第二个候选存在", http.Header{ + "X-Request-Id": {"openai-2"}, + "Request-Id": {"anthropic-2"}, + }, "openai-2"}, + {"无候选头返回空", http.Header{"Content-Type": {"application/json"}}, ""}, + {"空值候选继续向后找", http.Header{ + "X-Oneapi-Request-Id": {""}, + "X-Request-Id": {"fallback"}, + }, "fallback"}, + {"nil Header 返回空", nil, ""}, + {"超长截断到 128 字节", http.Header{"X-Request-Id": {string(make([]byte, 200))}}, string(make([]byte, 128))}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ExtractUpstreamRequestID(c.header); got != c.want { + t.Fatalf("got %q, want %q", got, c.want) + } + }) + } +} 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 78d8d7e..4831a3c 100644 --- a/relay/relaykit_bridge/stream.go +++ b/relay/relaykit_bridge/stream.go @@ -2,7 +2,7 @@ package relaykit_bridge import ( "context" - "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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,17 +115,57 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr transferredTextLen += len(*choice.Delta.ReasoningContent) } } - data, err := json.Marshal(streamChunk) - if err != nil { - return err - } - if err := helper.WriteSSEData(safeWriter, string(data)); err != nil { - writeFailed = true + if err := helper.WriteSSEDataJSON(safeWriter, streamChunk); err != nil { + // 序列化失败与写客户端失败必须分开:前者是转换器产出了不可序列化的 chunk, + // 后者才说明客户端已不可达(writeFailed 驱动上层按流中断结算、不再补写 [DONE]) + if !errors.Is(err, helper.ErrSSEPayloadMarshal) { + writeFailed = true + } return err } 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" @@ -123,8 +175,7 @@ func convertStreamViaRelaykit(ctx context.Context, info *common.RelayInfo, upstr Model: info.OriginModelName, Choices: []dto.StreamChoice{{Index: 0, FinishReason: &stop}}, } - data, _ := json.Marshal(terminal) - _ = helper.WriteSSEData(safeWriter, string(data)) + _ = helper.WriteSSEDataJSON(safeWriter, terminal) } setEndReason := func(reason common.StreamEndReason, err error) { @@ -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/relay/taskchannel/ali/adaptor.go b/relay/taskchannel/ali/adaptor.go index 7496bda..3ced7ac 100644 --- a/relay/taskchannel/ali/adaptor.go +++ b/relay/taskchannel/ali/adaptor.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "github.com/qianfree/team-api/relay/common" @@ -21,6 +22,10 @@ func init() { // ==================== 请求/响应结构体 ==================== +// wan3MaxDurationSeconds wan3.0 智能时长模式(duration=-1)的输出上限(秒)。 +// 智能时长下输出长度不可预知,预扣按该上限冻结,结算按上游 usage.output_video_duration 多退少补 +const wan3MaxDurationSeconds = 30.0 + // dashScopeVideoRequest DashScope 视频生成请求 type dashScopeVideoRequest struct { Model string `json:"model"` @@ -29,9 +34,18 @@ type dashScopeVideoRequest struct { } type dashScopeVideoInput struct { - Prompt string `json:"prompt"` + Prompt string `json:"prompt,omitempty"` NegativePrompt string `json:"negative_prompt,omitempty"` AudioURL string `json:"audio_url,omitempty"` + // Media wan3.0 全能参考模式的媒体素材数组(first_frame/last_frame/reference_image/ + // reference_video/reference_audio/file/link);wan2.x 旧格式不填 + Media []dashScopeMedia `json:"media,omitempty"` +} + +// dashScopeMedia wan3.0 input.media 数组元素 +type dashScopeMedia struct { + Type string `json:"type"` + URL string `json:"url"` } type dashScopeVideoParams struct { @@ -42,6 +56,8 @@ type dashScopeVideoParams struct { PromptExtend *bool `json:"prompt_extend,omitempty"` Watermark *bool `json:"watermark,omitempty"` Seed *int `json:"seed,omitempty"` + // Audio wan3.0 输出视频是否包含音频(开关价格相同) + Audio *bool `json:"audio,omitempty"` } // aliMetadata Ali DashScope metadata 参数结构体(用于 UnmarshalMetadata 映射) @@ -55,6 +71,7 @@ type aliMetadata struct { PromptExtend *bool `json:"prompt_extend,omitempty"` Watermark *bool `json:"watermark,omitempty"` Seed *int `json:"seed,omitempty"` + // Audio wan3.0 输出音频开关("on"/"off" 通用词汇由 buildVideoRequest 归一,此处不承载) } // dashScopeImageRequest DashScope 图片生成请求 @@ -95,7 +112,7 @@ type dashScopeTaskResponse struct { Usage struct { Duration float64 `json:"duration"` VideoCount int `json:"video_count"` - OutputVideoDuration int `json:"output_video_duration"` + OutputVideoDuration float64 `json:"output_video_duration"` SR int `json:"SR"` Ratio string `json:"ratio"` ImageCount int `json:"image_count"` @@ -119,14 +136,14 @@ func (a *AliAdaptor) Init(info *common.RelayInfo) { } // detectVideo 根据模型名判断是否为视频生成。 -// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2. 开头也不是视频,必须先排除, +// 注意:万相图片模型(-t2i 文生图、-i2i 图像编辑)即使以 wan2./wan3. 开头也不是视频,必须先排除, // 否则会被误判为视频而 POST 到 video-synthesis 端点,被上游以 "url error" 拒绝。 func (a *AliAdaptor) detectVideo(modelName string) bool { m := strings.ToLower(modelName) if strings.Contains(m, "-t2i") || strings.Contains(m, "-i2i") { return false } - return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wanx2.1-t2v") + return strings.HasPrefix(m, "wan2.") || strings.HasPrefix(m, "wan3.") || strings.HasPrefix(m, "wanx2.1-t2v") } func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { @@ -140,25 +157,50 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod return nil } -func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +// EstimateBilling 估算任务费用(提交前)。 +// 注意:此处自行从 body 提取模型名判定视频/图片,不依赖 BuildRequestBody 的 a.isVideo 副作用—— +// 管线中 EstimateBilling(第 6 步)先于 BuildRequestBody(第 7 步)执行,旧实现读 a.isVideo +// 恒为 false,视频时长信号从未上报(预扣恒按引擎默认 5s 估),本次修复。 +func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + ratios := map[string]any{"base": 1.0} var req map[string]any if json.Unmarshal(body, &req) != nil { return ratios } - if a.isVideo { - // duration 影响计费 + modelName, _ := req["model"].(string) + if a.detectVideo(modelName) { metadata := taskchannel.ExtractMetadata(req) - var meta struct { - Duration *int `json:"duration"` - } - if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 { - ratios["duration"] = float64(*meta.Duration) + var meta aliMetadata + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + // 分辨率事实值:per_second 矩阵查价键(480P/720P/1080P 原值)。 + // OpenAI Videos 路径无 resolution 字段,wan3.0 按 size 归一档位上报, + // 与 buildVideoRequest 的折算口径同源 + if meta.Resolution != "" { + ratios["spec.resolution"] = meta.Resolution + } else if strings.HasPrefix(strings.ToLower(modelName), "wan3.") && meta.Size != "" { + if r := normalizeWan3Resolution(meta.Size); r != "" { + ratios["spec.resolution"] = r + } + } + // 时长事实值:duration>0 用请求值;duration=-1(wan3.0 智能时长)按输出上限冻结, + // 结算以 usage.output_video_duration 多退少补 + if meta.Duration != nil { + d := float64(*meta.Duration) + if d == -1 { + d = wan3MaxDurationSeconds + } + if d > 0 { + ratios["duration"] = d + ratios["spec.duration"] = d + } + } } + // 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata if seconds, ok := req["seconds"].(string); ok { if d, err := parseInt(seconds); err == nil && d > 0 { ratios["duration"] = float64(d) + ratios["spec.duration"] = float64(d) } } } else { @@ -171,7 +213,7 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod return ratios } -func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -248,8 +290,33 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an } } + // wan3.0 专属适配:媒体素材数组 + 音频开关 + size 档位映射。 + // wan2.x 保持旧格式不动(其 size 是原生尺寸语义,不可参与档位映射) + if strings.HasPrefix(strings.ToLower(modelName), "wan3.") { + dsReq.Input.Media = buildWan3Media(req, metadata) + if sound, ok := metadata["sound"].(string); ok && sound != "" { + audio := sound == "on" + params.Audio = &audio + } + if params.Resolution == "" && meta.Size != "" { + if r := normalizeWan3Resolution(meta.Size); r != "" { + params.Resolution = r + } + } + // wan3.0 协议无 size 参数:size 只作档位折算源,任何情况不透传上游 + //(wan2.x 的 size 是原生尺寸语义,不进本分支、不受影响) + params.Size = "" + // ratio 兜底:通用词汇 aspect_ratio(OpenAI Videos 双写键,部分链路只写该键) + if params.Ratio == "" { + if ar, ok := metadata["aspect_ratio"].(string); ok { + params.Ratio = ar + } + } + } + if params.Resolution != "" || params.Ratio != "" || params.Size != "" || - params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil || params.Seed != nil { + params.Duration != nil || params.PromptExtend != nil || params.Watermark != nil || + params.Seed != nil || params.Audio != nil { dsReq.Parameters = params } @@ -260,6 +327,83 @@ func (a *AliAdaptor) buildVideoRequest(info *common.RelayInfo, req map[string]an return strings.NewReader(string(data)), nil } +// buildWan3Media 构建 wan3.0 input.media 数组(全能参考模式)。 +// 优先级:顶层 media(阿里原生入站原样透传)> metadata.image(OpenAI input_reference, +// 首帧语义对齐 Sora)> 顶层 images[](通用多图词汇 → 参考图)。均无则返回 nil(纯文生视频) +func buildWan3Media(req map[string]any, metadata map[string]any) []dashScopeMedia { + if raw, ok := req["media"].([]any); ok && len(raw) > 0 { + media := make([]dashScopeMedia, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + t, _ := m["type"].(string) + u, _ := m["url"].(string) + if t == "" || u == "" { + continue + } + media = append(media, dashScopeMedia{Type: t, URL: u}) + } + if len(media) > 0 { + return media + } + } + if img, ok := metadata["image"].(string); ok && img != "" { + return []dashScopeMedia{{Type: "first_frame", URL: img}} + } + if imgs, ok := req["images"].([]any); ok && len(imgs) > 0 { + media := make([]dashScopeMedia, 0, len(imgs)) + for _, item := range imgs { + if u, ok := item.(string); ok && u != "" { + media = append(media, dashScopeMedia{Type: "reference_image", URL: u}) + } + } + if len(media) > 0 { + return media + } + } + return nil +} + +// normalizeWan3Resolution 把 size 值归一为 wan3.0 分辨率档位: +// 裸档位词汇("720P"/"480p")直接归一为大写档位;WxH 尺寸("1280x720")按短边归档; +// 其余返回空串(上游取默认 1080P)。wan2.x 的 size 是原生尺寸语义,不得走此函数。 +func normalizeWan3Resolution(size string) string { + upper := strings.ToUpper(strings.TrimSpace(size)) + switch upper { + case "480P", "720P", "1080P": + return upper + } + return resolutionFromSize(size) +} + +// resolutionFromSize 从尺寸字符串(如 "1280x720")按短边映射 wan3.0 分辨率档位。 +// 竖版尺寸(720x1280)同样按短边归档;解析失败返回空串(上游取默认 1080P) +func resolutionFromSize(size string) string { + parts := strings.SplitN(strings.ToLower(strings.TrimSpace(size)), "x", 2) + if len(parts) != 2 { + return "" + } + w, errW := strconv.Atoi(strings.TrimSpace(parts[0])) + h, errH := strconv.Atoi(strings.TrimSpace(parts[1])) + if errW != nil || errH != nil || w <= 0 || h <= 0 { + return "" + } + short := w + if h < short { + short = h + } + switch { + case short <= 500: + return "480P" + case short <= 800: + return "720P" + default: + return "1080P" + } +} + func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]any, modelName string) (io.Reader, error) { dsReq := dashScopeImageRequest{ Input: dashScopeImageInput{}, @@ -305,12 +449,12 @@ func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]an return strings.NewReader(string(data)), nil } -func (a *AliAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *AliAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, url, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -363,7 +507,20 @@ func (a *AliAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *commo return result.Output.TaskID, body, nil } -func (a *AliAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +// ExtractUpstreamRequestID 从 DashScope 提交响应体顶层提取 request_id(调用追踪 ID)。 +// 与 output.task_id(任务句柄,轮询查询用)互补:在阿里云日志/工单中定位一次提交调用时用它 +func (a *AliAdaptor) ExtractUpstreamRequestID(body []byte) string { + var resp dashScopeSubmitResponse + if json.Unmarshal(body, &resp) != nil { + return "" + } + return resp.RequestID +} + +// 确保实现上游调用追踪 ID 提取可选能力 +var _ common.UpstreamRequestIDExtractor = (*AliAdaptor)(nil) + +func (a *AliAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -373,7 +530,7 @@ func (a *AliAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.R } url := fmt.Sprintf("%s/api/v1/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -407,6 +564,14 @@ func (a *AliAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { if resp.Output.VideoURL != "" { info.ResultURL = resp.Output.VideoURL } + // 素材计量(wan3.0 按秒计费):实际输出秒数 + 实际分辨率, + // 供 generic per_second 结算按「实际秒数 × 实际档位单价」多退少补(方案 A) + if resp.Usage.OutputVideoDuration > 0 { + info.MaterialUsage = &common.TaskMaterialUsage{ + OutputSeconds: resp.Usage.OutputVideoDuration, + Resolution: resolutionFromSR(resp.Usage.SR), + } + } // 图片结果 if len(resp.Output.Results) > 0 { info.ResultURL = resp.Output.Results[0].URL @@ -446,3 +611,12 @@ func parseInt(s string) (int, error) { _, err := fmt.Sscanf(s, "%d", &n) return n, err } + +// resolutionFromSR 上游 usage.SR(分辨率短边整数,如 720)→ per_second 矩阵键("720P")。 +// 0/异常值返回空串(结算查价回退 ratios 的 spec.resolution) +func resolutionFromSR(sr int) string { + if sr <= 0 { + return "" + } + return strconv.Itoa(sr) + "P" +} diff --git a/relay/taskchannel/ali/adaptor_test.go b/relay/taskchannel/ali/adaptor_test.go new file mode 100644 index 0000000..2b14766 --- /dev/null +++ b/relay/taskchannel/ali/adaptor_test.go @@ -0,0 +1,362 @@ +package ali + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" +) + +func newTestRelayInfo() *common.RelayInfo { + // ChannelMeta 必须非空(BuildRequestBody 读取 IsModelMapped/UpstreamModelName) + return &common.RelayInfo{ChannelMeta: &common.ChannelMeta{}} +} + +func TestDetectVideo(t *testing.T) { + a := &AliAdaptor{} + video := []string{ + "wan3.0-video", "wan3.0-video-prime", "WAN3.0-VIDEO", + "wan2.7-t2v-2026-04-25", "wan2.2-t2v-plus", "wanx2.1-t2v-turbo", + } + for _, m := range video { + if !a.detectVideo(m) { + t.Errorf("%s should be detected as video", m) + } + } + image := []string{ + "wanx2.1-t2i-ediff", "wanx2.5-image-edit", "wanx-v1", + "qwen-image-generation", "flux-dev", + } + for _, m := range image { + if a.detectVideo(m) { + t.Errorf("%s should be detected as image", m) + } + } +} + +// 回归:EstimateBilling 不再依赖 BuildRequestBody 的 isVideo 副作用(管线中前者先于后者执行, +// 旧实现视频时长信号从未上报),此处直接对原始 body 求值。 +func TestEstimateBilling_VideoSpecSignals(t *testing.T) { + a := &AliAdaptor{} + + // wan3.0 + metadata duration/resolution → spec.* 上报 + ratios := a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p","metadata":{"duration":8,"resolution":"720P"}}`)) + if ratios["spec.duration"] != 8.0 || ratios["duration"] != 8.0 { + t.Fatalf("spec.duration signal lost: %v", ratios) + } + if ratios["spec.resolution"] != "720P" { + t.Fatalf("spec.resolution signal lost: %v", ratios) + } + + // 智能时长 -1:按 wan3.0 上限 30s 冻结(结算按实际秒数多退少补) + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p","metadata":{"duration":-1,"resolution":"1080P"}}`)) + if ratios["spec.duration"] != 30.0 { + t.Fatalf("smart duration should freeze at 30s: %v", ratios) + } + if ratios["spec.resolution"] != "1080P" { + t.Fatalf("spec.resolution signal lost: %v", ratios) + } + + // 顶层 seconds(OpenAI Videos 通用词汇)优先于 metadata + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p","seconds":"12","metadata":{"duration":5}}`)) + if ratios["spec.duration"] != 12.0 { + t.Fatalf("seconds should override metadata duration: %v", ratios) + } + + // OpenAI Videos 路径无 resolution 字段:wan3.0 按 size 归一档位上报(裸档位/WxH 均可) + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p","metadata":{"size":"720P","duration":5}}`)) + if ratios["spec.resolution"] != "720P" { + t.Fatalf("size tier vocabulary should report spec.resolution: %v", ratios) + } + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p","metadata":{"size":"1280x720","duration":5}}`)) + if ratios["spec.resolution"] != "720P" { + t.Fatalf("size WxH should report spec.resolution: %v", ratios) + } + + // wan2.x 的 size 是原生尺寸语义,不得折算档位(预扣按矩阵兜底档走) + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`)) + if _, has := ratios["spec.resolution"]; has { + t.Fatalf("wan2 size must not derive spec.resolution: %v", ratios) + } + + // 未传时长:不产出 spec.duration(引擎按默认 5s 估) + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wan3.0-video","prompt":"p"}`)) + if _, has := ratios["spec.duration"]; has { + t.Fatalf("no duration signal expected: %v", ratios) + } + + // 图片模型:n>1 产出 count,不产出时长/分辨率信号 + ratios = a.EstimateBilling(context.Background(), newTestRelayInfo(), []byte( + `{"model":"wanx-v1","prompt":"p","n":4}`)) + if ratios["count"] != 4.0 { + t.Fatalf("image count signal lost: %v", ratios) + } + if _, has := ratios["spec.duration"]; has { + t.Fatalf("image model must not carry duration signal: %v", ratios) + } +} + +func TestBuildVideoRequest_Wan3NativeMedia(t *testing.T) { + a := &AliAdaptor{} + body := []byte(`{ + "model": "wan3.0-video", + "prompt": "p", + "media": [ + {"type": "reference_video", "url": "https://v.mp4"}, + {"type": "reference_audio", "url": "https://a.mp3"} + ], + "metadata": {"resolution": "480P", "ratio": "16:9", "duration": 5, "sound": "off"} + }`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build: %v", err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_video" || req.Input.Media[1].URL != "https://a.mp3" { + t.Fatalf("media passthrough lost: %+v", req.Input.Media) + } + if req.Parameters == nil || req.Parameters.Resolution != "480P" || req.Parameters.Ratio != "16:9" { + t.Fatalf("parameters lost: %+v", req.Parameters) + } + if req.Parameters.Audio == nil || *req.Parameters.Audio { + t.Fatalf("sound=off should map to audio=false: %+v", req.Parameters.Audio) + } +} + +func TestBuildVideoRequest_OpenAIVocabulary(t *testing.T) { + a := &AliAdaptor{} + // OpenAI Videos 通用词汇:metadata.image → first_frame;size 短边 → resolution;sound → audio + body := []byte(`{ + "model": "wan3.0-video", + "prompt": "p", + "seconds": "8", + "images": ["https://x/1.png"], + "metadata": {"image": "https://x/ref.png", "size": "1280x720", "sound": "on", "aspect_ratio": "9:16"} + }`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build: %v", err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + if len(req.Input.Media) != 1 || req.Input.Media[0].Type != "first_frame" || req.Input.Media[0].URL != "https://x/ref.png" { + t.Fatalf("metadata.image should map to first_frame: %+v", req.Input.Media) + } + if req.Parameters == nil || req.Parameters.Duration == nil || *req.Parameters.Duration != 8 { + t.Fatalf("seconds → duration lost: %+v", req.Parameters) + } + if req.Parameters.Resolution != "720P" { + t.Fatalf("size short-edge → resolution lost: %+v", req.Parameters) + } + if req.Parameters.Size != "" { + t.Fatalf("wan3.0 must not carry size after resolution derived: %+v", req.Parameters) + } + if req.Parameters.Ratio != "9:16" { + t.Fatalf("aspect_ratio → ratio lost: %+v", req.Parameters) + } + if req.Parameters.Audio == nil || !*req.Parameters.Audio { + t.Fatalf("sound=on should map to audio=true: %+v", req.Parameters.Audio) + } +} + +func TestBuildVideoRequest_MultiImagesFallback(t *testing.T) { + a := &AliAdaptor{} + // 无 metadata.image 时,顶层 images[] → reference_image 数组 + body := []byte(`{"model":"wan3.0-video","prompt":"p","images":["https://x/1.png","https://x/2.png"]}`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build: %v", err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + if len(req.Input.Media) != 2 || req.Input.Media[0].Type != "reference_image" || req.Input.Media[1].URL != "https://x/2.png" { + t.Fatalf("images fallback lost: %+v", req.Input.Media) + } +} + +// 回归:wan2.x 旧格式不引入 wan3.0 专属行为(无 media、无 audio、size 不派生档位) +func TestBuildVideoRequest_Wan2Unchanged(t *testing.T) { + a := &AliAdaptor{} + body := []byte(`{"model":"wan2.2-t2v-plus","prompt":"p","metadata":{"size":"1024x576","duration":5}}`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build: %v", err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + if len(req.Input.Media) != 0 { + t.Fatalf("wan2 request must not carry media: %+v", req.Input.Media) + } + if req.Parameters != nil && req.Parameters.Audio != nil { + t.Fatalf("wan2 request must not carry audio: %+v", req.Parameters) + } + if req.Parameters == nil || req.Parameters.Resolution != "" { + t.Fatalf("wan2 size must not derive resolution: %+v", req.Parameters) + } + if req.Parameters.Size != "1024*576" { + t.Fatalf("wan2 size passthrough broken: %+v", req.Parameters) + } +} + +func TestParseTaskResult_Wan3MaterialUsage(t *testing.T) { + a := &AliAdaptor{} + body := []byte(`{ + "output": {"task_id": "t1", "task_status": "SUCCEEDED", "video_url": "https://v.mp4"}, + "usage": {"video_count": 1, "duration": 5.0, "output_video_duration": 5.13, "fps": 30, "SR": 720, "ratio": "16:9"} + }`) + info, err := a.ParseTaskResult(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://v.mp4" { + t.Fatalf("unexpected info: %+v", info) + } + if info.MaterialUsage == nil { + t.Fatal("expected material usage for per_second settlement") + } + if info.MaterialUsage.OutputSeconds != 5.13 { + t.Fatalf("output seconds lost: %+v", info.MaterialUsage) + } + if info.MaterialUsage.Resolution != "720P" { + t.Fatalf("SR → resolution normalization broken: %+v", info.MaterialUsage) + } + + // usage 缺失(wan2.x 响应):MaterialUsage 为 nil,结算回退预扣口径 + info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t2","task_status":"SUCCEEDED","video_url":"https://v2.mp4"}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.MaterialUsage != nil { + t.Fatalf("no usage → no material usage: %+v", info.MaterialUsage) + } + + // SR=0:Resolution 留空(结算查价回退 ratios 档位) + info, err = a.ParseTaskResult([]byte(`{"output":{"task_id":"t3","task_status":"SUCCEEDED","video_url":"u"},"usage":{"output_video_duration":5.0,"SR":0}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.MaterialUsage == nil || info.MaterialUsage.Resolution != "" { + t.Fatalf("zero SR should yield empty resolution: %+v", info.MaterialUsage) + } +} + +func TestExtractUpstreamRequestID(t *testing.T) { + a := &AliAdaptor{} + // DashScope 提交成功响应:顶层 request_id 提取,与 output.task_id 互不干扰 + if got := a.ExtractUpstreamRequestID([]byte( + `{"output":{"task_id":"tsk-abc","task_status":"PENDING"},"request_id":"req-123","code":null,"message":""}`)); got != "req-123" { + t.Fatalf("request_id lost: %q", got) + } + // 错误响应同样带顶层 request_id(DashScope 错误时 code/message 非空) + if got := a.ExtractUpstreamRequestID([]byte( + `{"request_id":"req-err","code":"InvalidApiKey","message":"Invalid API-key"}`)); got != "req-err" { + t.Fatalf("error response request_id lost: %q", got) + } + // 缺失/非法 body:返回空串不报错 + if got := a.ExtractUpstreamRequestID([]byte(`{"output":{"task_id":"t"}}`)); got != "" { + t.Fatalf("missing request_id should yield empty: %q", got) + } + if got := a.ExtractUpstreamRequestID([]byte(`not json`)); got != "" { + t.Fatalf("invalid body should yield empty: %q", got) + } +} + +func TestResolutionFromSize(t *testing.T) { + cases := map[string]string{ + "1280x720": "720P", + "720x1280": "720P", // 竖版按短边 + "854x480": "480P", + "1920x1080": "1080P", + "1080x1920": "1080P", + "1024x1024": "1080P", // 正方形短边 1024 → 1080P + "invalid": "", + "ax720": "", + } + for size, want := range cases { + if got := resolutionFromSize(size); got != want { + t.Errorf("resolutionFromSize(%q) = %q, want %q", size, got, want) + } + } +} + +// 裸档位词汇(OpenAI 端点 size 直传 wan3.0 原生词汇)归一; +// WxH 尺寸走短边归档,无效值返回空串 +func TestNormalizeWan3Resolution(t *testing.T) { + cases := map[string]string{ + "720P": "720P", + "480p": "480P", // 小写归一为大写档位 + " 1080p ": "1080P", + "1280x720": "720P", + "1024x576": "720P", + "4K": "", // 非法键必须返回空串,防止把 "4K" 之类键透传上游 + "768P": "", // MiniMax 档位非 wan3.0 词汇,不归一 + "not-a-tie": "", + } + for size, want := range cases { + if got := normalizeWan3Resolution(size); got != want { + t.Errorf("normalizeWan3Resolution(%q) = %q, want %q", size, got, want) + } + } +} + +// OpenAI 端点 size 直传 wan3.0 原生档位词汇("720P"/"480p"): +// 直接作为 resolution 下发上游,且不再透传 wan3.0 不认识的 size 参数 +func TestBuildVideoRequest_SizeTierVocabulary(t *testing.T) { + for _, size := range []string{"720P", "480p", "1080p"} { + a := &AliAdaptor{} + body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"` + size + `"}}`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build (%s): %v", size, err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode (%s): %v", size, err) + } + want := strings.ToUpper(size) + if req.Parameters == nil || req.Parameters.Resolution != want { + t.Fatalf("size=%s should derive resolution=%s: %+v", size, want, req.Parameters) + } + if req.Parameters.Size != "" { + t.Fatalf("wan3.0 request must not carry size param (input %s): %+v", size, req.Parameters) + } + } +} + +// 回归:metadata.size 无法归一为档位(如 MiniMax 词汇误传给 wan3.0)时, +// resolution 留空走上游默认 1080P,原 size 值不再透传 +func TestBuildVideoRequest_SizeUnrecognized(t *testing.T) { + a := &AliAdaptor{} + body := []byte(`{"model":"wan3.0-video","prompt":"p","metadata":{"size":"768P"}}`) + r, err := a.BuildRequestBody(context.Background(), newTestRelayInfo(), body) + if err != nil { + t.Fatalf("build: %v", err) + } + var req dashScopeVideoRequest + if err := json.NewDecoder(r).Decode(&req); err != nil { + t.Fatalf("decode: %v", err) + } + // 归一失败且无其他参数时 Parameters 整体为 nil(不下发 parameters 节点), + // 非 nil 时 resolution/size 必须为空(上游默认 1080P) + if req.Parameters != nil && (req.Parameters.Resolution != "" || req.Parameters.Size != "") { + t.Fatalf("unrecognized size should leave resolution/size empty: %+v", req.Parameters) + } +} diff --git a/relay/taskchannel/ali/constants.go b/relay/taskchannel/ali/constants.go index d00161d..b10f9e6 100644 --- a/relay/taskchannel/ali/constants.go +++ b/relay/taskchannel/ali/constants.go @@ -5,6 +5,8 @@ const channelName = "Ali" // ModelList 阿里云 DashScope 支持异步生成的模型列表(图片 + 视频) var ModelList = []string{ // 视频生成 + "wan3.0-video", + "wan3.0-video-prime", "wan2.7-t2v-2026-04-25", "wan2.6-t2v", "wan2.6-t2v-us", diff --git a/relay/taskchannel/gemini/adaptor.go b/relay/taskchannel/gemini/adaptor.go index 85b324f..b1d3e6b 100644 --- a/relay/taskchannel/gemini/adaptor.go +++ b/relay/taskchannel/gemini/adaptor.go @@ -42,12 +42,20 @@ func (a *GeminiAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, return nil } -func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + ratios := map[string]any{"base": 1.0} var req openAIVideoRequest if json.Unmarshal(body, &req) == nil { resolution := parseResolution(req.Metadata.Resolution) duration := req.Metadata.Duration + // per_second 事实键:真实秒数 + 规格原值(矩阵查价键) + if duration > 0 { + ratios["spec.duration"] = float64(duration) + } + if resolution != "" { + ratios["spec.resolution"] = resolution + } + // 存量 token 伪装路径乘数(语义为乘数非秒数,保持原行为) if resolution == "1080p" { ratios["resolution"] = 1.5 } else if resolution == "4k" { @@ -60,7 +68,7 @@ func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, return ratios } -func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -116,12 +124,12 @@ func (a *GeminiAdaptor) BuildRequestBody(_ context.Context, info *common.RelayIn return strings.NewReader(string(data)), nil } -func (a *GeminiAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *GeminiAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -163,7 +171,7 @@ func (a *GeminiAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *co return result.Name, body, nil } -func (a *GeminiAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *GeminiAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -173,7 +181,7 @@ func (a *GeminiAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*htt } url := fmt.Sprintf("%s/v1beta/%s", strings.TrimRight(baseURL, "/"), data.TaskID) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } diff --git a/relay/taskchannel/kling/adaptor.go b/relay/taskchannel/kling/adaptor.go index 1f86878..c7cdcf4 100644 --- a/relay/taskchannel/kling/adaptor.go +++ b/relay/taskchannel/kling/adaptor.go @@ -116,8 +116,8 @@ func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, b return nil } -func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + ratios := map[string]any{"base": 1.0} var req map[string]any if json.Unmarshal(body, &req) != nil { return ratios @@ -145,7 +145,7 @@ func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, b return ratios } -func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -271,7 +271,7 @@ func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInf return strings.NewReader(string(data)), nil } -func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *KlingAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") // 根据 taskType 决定 URL @@ -285,14 +285,15 @@ func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requ reqURL = fmt.Sprintf("%s/v1/videos/text2video", baseURL) } - req, err := http.NewRequest(http.MethodPost, reqURL, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if err := a.BuildRequestHeader(req.Header, info); err != nil { return nil, fmt.Errorf("setup header: %w", err) } - client := &http.Client{Timeout: 120 * 1e9} + // 换共享连接池 client:超时语义等价(120s),并带上渠道代理与调试日志传输层包装 + client := common.NewPooledClient(120, info.ChannelMeta.Settings.UseProxy) return client.Do(req) } @@ -322,7 +323,7 @@ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *com return result.Data.TaskID, body, nil } -func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *KlingAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` TaskType string `json:"task_type"` @@ -338,7 +339,7 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -350,7 +351,8 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 30 * 1e9} + // 换共享连接池 client:超时语义等价(30s),并带上调试日志传输层包装 + client := common.NewPooledClient(30, false) return client.Do(req) } diff --git a/relay/taskchannel/midjourney/adaptor.go b/relay/taskchannel/midjourney/adaptor.go index 1c382c2..ee1f4d7 100644 --- a/relay/taskchannel/midjourney/adaptor.go +++ b/relay/taskchannel/midjourney/adaptor.go @@ -56,8 +56,8 @@ func (a *MjAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ [] return nil } -func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]any { + ratios := map[string]any{"base": 1.0} action := constant.TaskAction(a.extractAction()) switch action { case constant.TaskActionUpscale: @@ -70,7 +70,7 @@ func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ [] return ratios } -func (a *MjAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *MjAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -90,12 +90,12 @@ func (a *MjAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, return strings.NewReader(string(body)), nil } -func (a *MjAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *MjAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -147,7 +147,7 @@ func (a *MjAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common return result.Result, body, nil } -func (a *MjAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *MjAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { UpstreamTaskID string `json:"upstream_task_id"` UseProxy bool `json:"use_proxy"` @@ -157,7 +157,7 @@ func (a *MjAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Re } url := fmt.Sprintf("%s/mj/task/%s/fetch", strings.TrimRight(baseURL, "/"), data.UpstreamTaskID) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } diff --git a/relay/taskchannel/minimax/adaptor.go b/relay/taskchannel/minimax/adaptor.go new file mode 100644 index 0000000..febe884 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor.go @@ -0,0 +1,883 @@ +package minimax + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +// MiniMax 视频生成任务适配器,覆盖两代协议(按上游模型名分派,见 IsV2Model): +// - v2(MiniMax-H3 系列):POST /v2/video_generation 提交(content[] 多模态)→ +// GET /v2/query/video_generation/{task_id} 轮询,错误为 OpenAI 风格 OaiError; +// - v1(Hailuo 2.x 及旧型号):POST /v1/video_generation 提交(扁平字段)→ +// GET /v1/query/video_generation?task_id= 轮询,错误为 base_resp 信封 +// (HTTP 200 且 status_code != 0 即业务失败),成功后需二跳 +// GET /v1/files/retrieve?file_id= 换取 download_url(FetchTask 内链式完成)。 +// +// 请求体支持两种入站形态(由 BuildRequestBody 分流): +// - 原生形态:v2 顶层 content[] 数组 / v1 顶层 first_frame_image 等扁平字段(官方协议入站); +// - 通用形态:{model, prompt, seconds, images[], metadata{...}} 归一化任务体 +// (/v1/videos 与 /v1/video/generations 入站)。 + +func init() { + taskchannel.Register(constant.ProviderMiniMax, func() common.TaskAdaptor { + return &Adaptor{} + }) +} + +// ==================== 请求/响应结构体 ==================== + +// submitResponse v2 提交任务响应(VideoGenerationV2Resp) +type submitResponse struct { + TaskID string `json:"task_id"` +} + +// baseResp v1 接口的业务状态信封(HTTP 200 且 status_code != 0 表示业务失败) +type baseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` +} + +// submitResponseV1 v1 提交任务响应(VideoGenerationResp) +type submitResponseV1 struct { + TaskID string `json:"task_id"` + BaseResp *baseResp `json:"base_resp"` +} + +// taskQueryResponseV1 v1 任务查询响应(QueryVideoGenerationTaskResp)。 +// DownloadURL 为 FetchTask 二跳 files/retrieve 后合并进来的字段(非上游查询接口原生字段)。 +type taskQueryResponseV1 struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FileID string `json:"file_id"` + VideoWidth int `json:"video_width"` + VideoHeight int `json:"video_height"` + DownloadURL string `json:"download_url,omitempty"` + BaseResp *baseResp `json:"base_resp"` +} + +// fileRetrieveResponse v1 文件查询响应(RetrieveFileResp) +type fileRetrieveResponse struct { + File struct { + FileID int `json:"file_id"` + DownloadURL string `json:"download_url"` + } `json:"file"` + BaseResp *baseResp `json:"base_resp"` +} + +// oaiErrorBody v2 接口错误结构(OpenAI 风格顶层 error 对象) +type oaiErrorBody struct { + Error struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// IsV2Model 判断模型是否走 v2 H3 协议(content[] 多模态)。 +// 其余模型(Hailuo 2.x 及 T2V/I2V/S2V 旧型号)一律走 v1 扁平协议—— +// 官方 v2 接口明确「当前支持模型:MiniMax-H3」,未列型号默认按 v1 处理。 +func IsV2Model(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "minimax-h3") +} + +// ==================== Adaptor 实现 ==================== + +// Adaptor MiniMax H3 视频任务适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *Adaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"} + } + // v2 原生形态:官方约束每次请求必须包含一个非空 text 项 + if content, ok := req["content"].([]any); ok { + if !contentHasText(content) { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"} + } + return nil + } + // v1 原生形态:文生视频 prompt 必填、图生视频 first_frame_image 必填、主体参考 subject_reference 必填(三选一) + if hasAnyKey(req, "first_frame_image", "last_frame_image", "subject_reference") { + if !hasAnyKey(req, "prompt", "first_frame_image", "subject_reference") { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of prompt / first_frame_image / subject_reference is required"} + } + return nil + } + // 通用形态:prompt 必填(上游 text 项 / v1 prompt 的唯一来源) + if prompt, ok := req["prompt"].(string); !ok || strings.TrimSpace(prompt) == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"} + } + return nil +} + +// EstimateBilling 估算计费上下文。 +// 只写 spec.* 规格事实值(per_second 计费矩阵查价键 / 素材计费张数与输入视频存在性), +// 禁止附加 float 乘数键——ratios 中所有 float64 值会被计费引擎 applyRatioMultipliers 连乘, +// 混入非乘数键会改变费用(bool/字符串值不受影响,会被自动跳过)。 +// resolveDuration/resolveResolution 与 BuildRequestBody 共用,保证计费规格与实际生成规格一致。 +// spec.input_image_count 为提交可知的输入图片张数(素材计费预扣用;结算以官方 usage 为准); +// spec.has_input_video 标记提交含输入视频素材(时长未知,预扣按生成长度上限估,见 minimax-material 方案)。 +func (a *Adaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + req = nil + } + return map[string]any{ + "spec.duration": float64(resolveDuration(req)), + "spec.resolution": resolveResolution(req), + "spec.input_image_count": float64(countInputImages(req)), + "spec.has_input_video": hasInputVideo(req), + } +} + +// countInputImages 统计提交请求中的输入图片张数(与 buildGenericContent 的素材归并同源): +// v2 原生形态数 content[] 的 image_url 项;通用形态数 images[] + metadata.image + +// metadata.last_frame + metadata.content[] 的 image_url 项。图片张数不计音频/视频素材 +// (视频存在性另见 hasInputVideo,时长不可知由结算补)。 +func countInputImages(req map[string]any) int { + if req == nil { + return 0 + } + // v2 原生形态 + if content, ok := req["content"].([]any); ok { + count := 0 + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if t, _ := m["type"].(string); t == "image_url" { + count++ + } + } + } + return count + } + // 通用形态:与 buildGenericContent 相同的归并口径 + count := 0 + if images, ok := req["images"].([]any); ok { + count += len(images) + } + metadata := taskchannel.ExtractMetadata(req) + if _, ok := metadata["image"].(string); ok { + count++ + } + if _, ok := metadata["last_frame"].(string); ok { + count++ + } + var meta struct { + Content []map[string]any `json:"content"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + for _, item := range meta.Content { + if t, _ := item["type"].(string); t == "image_url" { + count++ + } + } + } + return count +} + +// hasInputVideo 判断提交请求是否含输入视频素材(与 buildGenericContent 的素材归并同源): +// v2 原生形态查 content[] 的 video_url 项;通用形态查 metadata.content[] 的 video_url 项。 +// 视频以 URL 提交、时长提交时不可知,素材计费据此在预扣时按生成长度上限冻结(15s,见 minimax-material 方案)。 +func hasInputVideo(req map[string]any) bool { + if req == nil { + return false + } + // v2 原生形态 + if content, ok := req["content"].([]any); ok { + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if t, _ := m["type"].(string); t == "video_url" { + return true + } + } + } + return false + } + // 通用形态:metadata.content[] 透传项(多模态参考,与 countInputImages 同口径) + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Content []map[string]any `json:"content"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + for _, item := range meta.Content { + if t, _ := item["type"].(string); t == "video_url" { + return true + } + } + } + return false +} + +func (a *Adaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { + return nil +} + +func (a *Adaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + base := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + if IsV2Model(dispatchModel(info)) { + return base + "/v2/video_generation", nil + } + return base + "/v1/video_generation", nil +} + +// dispatchModel 协议分派用的模型名(上游映射名优先,缺省回落请求模型名) +func dispatchModel(info *common.RelayInfo) string { + if info.ChannelMeta.UpstreamModelName != "" { + return info.ChannelMeta.UpstreamModelName + } + return info.OriginModelName +} + +func (a *Adaptor) BuildRequestHeader(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 +} + +func (a *Adaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("minimax: invalid request body: %w", err) + } + + modelName := resolveUpstreamModel(info, req) + + var out map[string]any + if IsV2Model(modelName) { + out = buildV2RequestBody(req, modelName) + } else { + out = buildV1RequestBody(req, modelName) + } + + data, err := json.Marshal(out) + if err != nil { + return nil, fmt.Errorf("marshal minimax request: %w", err) + } + return strings.NewReader(string(data)), nil +} + +// buildV2RequestBody v2(H3 系列)请求体:content[] 多模态。 +// 原生形态(顶层 content[])原样透传;通用形态从 prompt/images/metadata 组装。 +// 注意不透传 callback_url——上游回调携带上游 task_id,与网关公开 ID 对不上且会绕过网关轮询计费。 +func buildV2RequestBody(req map[string]any, modelName string) map[string]any { + out := map[string]any{ + "model": modelName, + "resolution": resolveResolution(req), + "duration": resolveDuration(req), + } + if content, ok := req["content"].([]any); ok && len(content) > 0 { + out["content"] = content + if ratio := resolveRatio(req); ratio != "" { + out["ratio"] = ratio + } + } else { + content := buildGenericContent(req) + if len(content) == 0 { + content = []map[string]any{{"type": "text", "text": ""}} + } + out["content"] = content + // t2v(纯文本输入)时官方要求 ratio 必填且不能为 adaptive;含图片输入时上游恒按 adaptive 处理,显式传值无害 + ratio := resolveRatio(req) + if ratio == "" { + ratio = defaultTextOnlyRatio + } + out["ratio"] = ratio + } + if wm, ok := req["aigc_watermark"].(bool); ok { + out["aigc_watermark"] = wm + } + return out +} + +// buildV1RequestBody v1(Hailuo 系列)请求体:扁平字段。 +// v1 原生入站的顶层字段(first_frame_image/last_frame_image/subject_reference/prompt_optimizer/ +// fast_pretreatment)与通用词汇(images[0]/metadata.image/metadata.last_frame 等)统一在此归并, +// 白名单式拷贝保证 callback_url 等网关侧字段不会外泄到上游。 +func buildV1RequestBody(req map[string]any, modelName string) map[string]any { + out := map[string]any{ + "model": modelName, + "resolution": resolveResolution(req), + "duration": resolveDuration(req), + } + if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" { + out["prompt"] = prompt + } + + // 首帧图:v1 原生顶层字段 > images[0]/metadata.image + firstFrame := stringField(req, "first_frame_image") + if firstFrame == "" { + firstFrame = genericFirstImage(req) + } + if firstFrame != "" { + out["first_frame_image"] = firstFrame + } + // 尾帧图:v1 原生顶层字段 > metadata.last_frame + lastFrame := stringField(req, "last_frame_image") + if lastFrame == "" { + lastFrame = metaString(req, "last_frame") + } + if lastFrame != "" { + out["last_frame_image"] = lastFrame + } + // 主体参考(S2V-01):v1 原生顶层字段 > metadata.subject_reference + if sr, ok := req["subject_reference"]; ok && sr != nil { + out["subject_reference"] = sr + } else if sr := metaAny(req, "subject_reference"); sr != nil { + out["subject_reference"] = sr + } + // 可选参数白名单透传 + for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "aigc_watermark"} { + if v, ok := req[key]; ok { + out[key] = v + } + } + return out +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if err := a.BuildRequestHeader(req.Header, info); err != nil { + return nil, fmt.Errorf("setup header: %w", err) + } + client := common.NewPooledClient(120, info.ChannelMeta.Settings.UseProxy) + return client.Do(req) +} + +func (a *Adaptor) DoResponse(_ context.Context, resp *http.Response, info *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"} + } + + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + // v2 用 OaiError、v1 用 base_resp,两种信封都尝试解析 + var errResp oaiErrorBody + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + } else { + var baseRespErr struct { + BaseResp *baseResp `json:"base_resp"` + } + if json.Unmarshal(body, &baseRespErr) == nil && baseRespErr.BaseResp != nil && baseRespErr.BaseResp.StatusMsg != "" { + msg = baseRespErr.BaseResp.StatusMsg + } + } + if msg == "" { + msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode) + } + return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: msg} + } + + if IsV2Model(dispatchModel(info)) { + var result submitResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + if result.TaskID == "" { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + return result.TaskID, body, nil + } + + // v1:HTTP 200 仍需检查 base_resp 业务码(status_code != 0 即业务失败) + var result submitResponseV1 + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + if result.BaseResp != nil && result.BaseResp.StatusCode != 0 { + msg := result.BaseResp.StatusMsg + if msg == "" { + msg = fmt.Sprintf("upstream business error %d", result.BaseResp.StatusCode) + } + return "", body, &common.TaskError{StatusCode: http.StatusBadRequest, Message: msg, ErrCode: fmt.Sprintf("%d", result.BaseResp.StatusCode)} + } + if result.TaskID == "" { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + return result.TaskID, body, nil +} + +func (a *Adaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + TaskID string `json:"task_id"` + UseProxy bool `json:"use_proxy"` + Model string `json:"model"` // 轮询器注入的上游模型名,用于 v1/v2 协议分派 + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("minimax: invalid task data: %w", err) + } + + base := strings.TrimRight(baseURL, "/") + if IsV2Model(data.Model) { + return fetchV2Task(ctx, base, apiKey, data.TaskID, data.UseProxy) + } + return fetchV1Task(ctx, base, apiKey, data.TaskID, data.UseProxy) +} + +// fetchV2Task v2 单任务查询:GET /v2/query/video_generation/{task_id} +func fetchV2Task(ctx context.Context, base, apiKey, taskID string, useProxy bool) (*http.Response, error) { + url := fmt.Sprintf("%s/v2/query/video_generation/%s", base, taskID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := common.NewPooledClient(30, useProxy) + return client.Do(req) +} + +// fetchV1Task v1 单任务查询:GET /v1/query/video_generation?task_id=...。 +// v1 协议成功后只给 file_id,需二跳 GET /v1/files/retrieve?file_id=... 换取限时 download_url—— +// 在此链式完成并把 download_url 合并进查询响应体(ParseTaskResult 与原生查询回放共用该字段)。 +// 二跳失败不改变任务状态判定:任务仍按 Success 结算,仅缺下载直链(客户端可稍后重查)。 +// 调试日志注意:两跳共用同一 ctx 捕获器,调试记录里 URL/headers 为二跳、body 为两跳拼接。 +func fetchV1Task(ctx context.Context, base, apiKey, taskID string, useProxy bool) (*http.Response, error) { + client := common.NewPooledClient(30, useProxy) + + queryURL := fmt.Sprintf("%s/v1/query/video_generation?task_id=%s", base, url.QueryEscape(taskID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // 仅在成功且拿到 file_id 时二跳;其余状态原样返回 + var probe taskQueryResponseV1 + if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &probe) != nil || probe.Status != "Success" || probe.FileID == "" { + return rebuiltResponse(resp.StatusCode, body), nil + } + + fileURL := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", base, url.QueryEscape(probe.FileID)) + fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + fileReq.Header.Set("Authorization", "Bearer "+apiKey) + + fileResp, err := client.Do(fileReq) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + defer fileResp.Body.Close() + fileBody, err := io.ReadAll(fileResp.Body) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + + var fileResult fileRetrieveResponse + if fileResp.StatusCode != http.StatusOK || json.Unmarshal(fileBody, &fileResult) != nil || + fileResult.File.DownloadURL == "" || + (fileResult.BaseResp != nil && fileResult.BaseResp.StatusCode != 0) { + // 二跳失败:保留原查询响应(download_url 缺失),任务仍可按 Success 结算 + return rebuiltResponse(resp.StatusCode, body), nil + } + + // 合并 download_url 进查询响应体 + var merged map[string]any + if json.Unmarshal(body, &merged) == nil { + merged["download_url"] = fileResult.File.DownloadURL + if newBody, err := json.Marshal(merged); err == nil { + return rebuiltResponse(resp.StatusCode, newBody), nil + } + } + return rebuiltResponse(resp.StatusCode, body), nil +} + +// rebuiltResponse 用已消费的 body 重建 http.Response(FetchTask 契约要求返回未读响应体) +func rebuiltResponse(statusCode int, body []byte) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } +} + +// ParseTaskResult 解析上游单任务查询响应。两代协议按响应形态自动区分: +// v2 为 {"task": {...}} 包装、v1 为扁平的 {task_id, status, file_id, ...}。 +// 注意:不填 ActualCost——per_second 计费下 RecalculateByTokens 返回 0,结算保持预扣金额, +// 而 duration 必填=产物时长,预扣即终价;填入 ActualCost 反而会用上游口径覆盖平台定价。 +func (a *Adaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp TaskQueryResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("minimax: parse task result: %w", err) + } + if resp.Task != nil { + return parseV2TaskResult(body, resp.Task) + } + return parseV1TaskResult(body) +} + +// parseV2TaskResult v2(H3)查询响应解析 +func parseV2TaskResult(body []byte, task *VideoTask) (*common.TaskInfo, error) { + info := &common.TaskInfo{Data: body} + + // 上游返回的 token 用量仅作记录(per_second 计费不按 token 结算) + if task.Usage != nil && task.Usage.TotalTokens > 0 { + info.TotalTokens = task.Usage.TotalTokens + info.CompletionTokens = task.Usage.CompletionTokens + info.PromptTokens = task.Usage.PromptTokens + } + + // 素材计量(input_seconds/output_seconds/input_image_count 等):素材计费方案的结算依据。 + // 官方 usage 携带任一按秒计量或图片张数字段即透出(全零不出,避免无素材任务占用结算链路) + if task.Usage != nil && (task.Usage.InputSeconds > 0 || task.Usage.OutputSeconds > 0 || + task.Usage.InputImageCount > 0 || task.Usage.InputAudioSeconds > 0 || task.Usage.TotalSeconds > 0) { + info.MaterialUsage = &common.TaskMaterialUsage{ + InputVideoSeconds: float64(task.Usage.InputSeconds), + InputImageCount: task.Usage.InputImageCount, + InputAudioSeconds: float64(task.Usage.InputAudioSeconds), + OutputSeconds: float64(task.Usage.OutputSeconds), + } + } + + switch task.Status { + case "queued": + info.Status = common.TaskStatusQueued + info.Progress = "10%" + case "running": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "succeeded": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + if task.Content != nil { + info.ResultURL = task.Content.URL + } + case "failed": + info.Status = common.TaskStatusFailure + info.FailReason = "video generation failed" + if task.Error != nil && task.Error.Message != "" { + info.FailReason = task.Error.Message + } + case "cancelled": + // 必须映射为终态 FAILURE:映射成非终态会让任务挂到超时兜底(30 分钟)才退款 + info.Status = common.TaskStatusFailure + info.FailReason = "task cancelled" + default: + info.Status = common.TaskStatusInProgress + info.Progress = "30%" + } + + return info, nil +} + +// parseV1TaskResult v1(Hailuo)查询响应解析(状态机 Preparing/Queueing/Processing/Success/Fail)。 +// download_url 为 FetchTask 二跳合并字段,成功时作为 ResultURL。 +func parseV1TaskResult(body []byte) (*common.TaskInfo, error) { + var resp taskQueryResponseV1 + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("minimax: parse v1 task result: %w", err) + } + + info := &common.TaskInfo{Data: body} + + switch resp.Status { + case "Preparing", "Queueing": + info.Status = common.TaskStatusQueued + info.Progress = "10%" + case "Processing": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "Success": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + info.ResultURL = resp.DownloadURL + case "Fail": + info.Status = common.TaskStatusFailure + info.FailReason = "video generation failed" + if resp.BaseResp != nil && resp.BaseResp.StatusMsg != "" { + info.FailReason = resp.BaseResp.StatusMsg + } + default: + info.Status = common.TaskStatusInProgress + info.Progress = "30%" + } + + return info, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return channelName +} + +// ==================== 归一化辅助函数 ==================== +// 以下函数被 EstimateBilling 与 BuildRequestBody 共用(计费一致性强约束): +// per_second 矩阵键按 spec.resolution 原值逐字符匹配,两处归一化若不一致会导致 +// 「按 768P 生成、按兜底价收费」的系统性少收。 + +// resolveDuration 提取时长秒数:顶层 duration(原生形态)> seconds > metadata.duration > 默认 6。 +func resolveDuration(req map[string]any) int { + if d, ok := parseIntAny(req["duration"]); ok && d > 0 { + return d + } + if d, ok := parseIntAny(req["seconds"]); ok && d > 0 { + return d + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Duration *int `json:"duration"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 { + return *meta.Duration + } + return defaultDurationSecs +} + +// resolveResolution 提取分辨率:顶层 resolution > metadata.resolution > metadata.size > 按模型默认档。 +// size 回退覆盖 /v1/videos 入站(该协议的分辨率就承载在 size 字段,原值透传,无换算)。 +// 默认档跟随官方口径:H3 与 Hailuo 2.x 默认 768P,T2V/I2V/S2V 旧型号默认 720P。 +func resolveResolution(req map[string]any) string { + if r, ok := req["resolution"].(string); ok && strings.TrimSpace(r) != "" { + return normalizeResolution(r) + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Resolution string `json:"resolution"` + Size string `json:"size"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + if strings.TrimSpace(meta.Resolution) != "" { + return normalizeResolution(meta.Resolution) + } + if strings.TrimSpace(meta.Size) != "" { + return normalizeResolution(meta.Size) + } + } + model, _ := req["model"].(string) + return defaultResolutionFor(model) +} + +// defaultResolutionFor 按模型取分辨率默认档(H3/Hailuo 2.x → 768P,其余 → 720P) +func defaultResolutionFor(model string) string { + m := strings.ToLower(strings.TrimSpace(model)) + if strings.HasPrefix(m, "minimax-h3") || strings.HasPrefix(m, "minimax-hailuo-2") { + return defaultResolution + } + return defaultResolutionV1 +} + +// resolveRatio 提取宽高比:顶层 ratio > metadata.ratio > metadata.aspect_ratio(kling 词汇);空 = 未指定。 +func resolveRatio(req map[string]any) string { + if r, ok := req["ratio"].(string); ok && strings.TrimSpace(r) != "" { + return r + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Ratio string `json:"ratio"` + AspectRatio string `json:"aspect_ratio"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + if meta.Ratio != "" { + return meta.Ratio + } + if meta.AspectRatio != "" { + return meta.AspectRatio + } + } + return "" +} + +// normalizeResolution 分辨率值规范:仅做两代协议官方档位的大小写规范(per_second 计费矩阵键 +// 逐字符匹配,大小写差异会导致查价错档),**不做任何档位换算**——非官方档位的值 +// 原样透传,由上游裁决(非法值上游 400,错误透传给客户端并全额退款)。 +// 官方档位全集:v2 的 480P/768P/2K + v1 的 720P/768P/1080P/512P。 +func normalizeResolution(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "480p": + return "480P" + case "720p": + return "720P" + case "768p": + return "768P" + case "1080p": + return "1080P" + case "512p": + return "512P" + case "2k": + return "2K" + } + return strings.TrimSpace(v) +} + +// parseIntAny 宽松解析整数(JSON number 解码为 float64,或字符串数字) +func parseIntAny(v any) (int, bool) { + switch n := v.(type) { + case float64: + if n > 0 { + return int(n), true + } + case string: + if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil && i > 0 { + return i, true + } + } + return 0, false +} + +// buildGenericContent 从归一化任务体组装 MiniMax content[]: +// prompt → text 项;images[0]/metadata.image → 首帧图;metadata.last_frame → 尾帧图; +// metadata.content[] → 追加透传(多模态参考,与 volcengine 适配器同词汇)。 +func buildGenericContent(req map[string]any) []map[string]any { + content := make([]map[string]any, 0, 4) + + if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" { + content = append(content, map[string]any{"type": "text", "text": prompt}) + } + + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Image string `json:"image"` + LastFrame string `json:"last_frame"` + Content []map[string]any `json:"content"` + } + _ = taskchannel.UnmarshalMetadata(metadata, &meta) + + firstFrame := meta.Image + if firstFrame == "" { + if images, ok := req["images"].([]any); ok && len(images) > 0 { + if url, ok := images[0].(string); ok { + firstFrame = url + } + } + } + if firstFrame != "" { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": firstFrame}, + "role": "first_frame", + }) + } + if meta.LastFrame != "" { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": meta.LastFrame}, + "role": "last_frame", + }) + } + content = append(content, meta.Content...) + + return content +} + +// contentHasText 判断原生 content[] 是否含非空 text 项 +func contentHasText(content []any) bool { + for _, item := range content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + return true + } + } + } + return false +} + +// resolveUpstreamModel 解析上游模型名(渠道模型映射优先) +func resolveUpstreamModel(info *common.RelayInfo, req map[string]any) string { + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + return info.ChannelMeta.UpstreamModelName + } + if m, ok := req["model"].(string); ok { + return m + } + return "" +} + +// ==================== v1 归并辅助 ==================== + +// hasAnyKey 判断请求 map 是否含任一指定键(值非空) +func hasAnyKey(req map[string]any, keys ...string) bool { + for _, key := range keys { + if v, ok := req[key]; ok && v != nil { + if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" { + return true + } + } + } + return false +} + +// stringField 取顶层字符串字段(非空) +func stringField(req map[string]any, key string) string { + if s, ok := req[key].(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +// metaString 取 metadata 内字符串字段(非空) +func metaString(req map[string]any, key string) string { + if v, ok := taskchannel.ExtractMetadata(req)[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +// metaAny 取 metadata 内任意类型字段(存在则返回) +func metaAny(req map[string]any, key string) any { + metadata := taskchannel.ExtractMetadata(req) + if v, ok := metadata[key]; ok { + return v + } + return nil +} + +// genericFirstImage 取通用形态的首帧图:images[0] > metadata.image +func genericFirstImage(req map[string]any) string { + if images, ok := req["images"].([]any); ok && len(images) > 0 { + if url, ok := images[0].(string); ok { + return strings.TrimSpace(url) + } + } + if v, ok := taskchannel.ExtractMetadata(req)["image"].(string); ok { + return strings.TrimSpace(v) + } + return "" +} diff --git a/relay/taskchannel/minimax/adaptor_test.go b/relay/taskchannel/minimax/adaptor_test.go new file mode 100644 index 0000000..a6ffe75 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor_test.go @@ -0,0 +1,691 @@ +package minimax + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// testInfo 构造带渠道元数据的 RelayInfo +func testInfo(baseURL, upstreamModel string, mapped bool) *common.RelayInfo { + return &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelID: 1, + ChannelType: int(constant.ProviderMiniMax), + BaseURL: baseURL, + ApiKey: "sk-test", + UpstreamModelName: upstreamModel, + IsModelMapped: mapped, + }, + } +} + +func buildBody(t *testing.T, a *Adaptor, info *common.RelayInfo, body string) map[string]any { + t.Helper() + reader, err := a.BuildRequestBody(context.Background(), info, []byte(body)) + if err != nil { + t.Fatalf("build request body: %v", err) + } + raw, _ := io.ReadAll(reader) + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("upstream body not json: %v, raw=%s", err, string(raw)) + } + return out +} + +// TestNormalizeResolution 仅做两代协议官方档位的大小写规范(计费矩阵键逐字符匹配), +// 非官方值原样透传、由上游裁决——不做任何档位换算。 +func TestNormalizeResolution(t *testing.T) { + cases := map[string]string{ + "480P": "480P", + "480p": "480P", + " 480P ": "480P", + "720p": "720P", + "768P": "768P", + "768p": "768P", + "1080p": "1080P", // v1 官方档位:仅规范大小写 + "512p": "512P", + "2K": "2K", + "2k": "2K", + "1280x720": "1280x720", // 非官方档位:原样透传(上游 400 + 退款) + "custom-tier": "custom-tier", + "": "", + } + for in, want := range cases { + if got := normalizeResolution(in); got != want { + t.Errorf("normalizeResolution(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateRequest(t *testing.T) { + a := &Adaptor{} + + // 通用形态:prompt 必填 + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3"}`)); err == nil { + t.Error("expected error when prompt missing in generic form") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi"}`)); err != nil { + t.Errorf("generic form with prompt should pass: %v", err) + } + + // 原生形态:content 必含非空 text 项 + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err == nil { + t.Error("expected error when native content has no text item") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":" "}]}`)); err == nil { + t.Error("expected error when native content text item is blank") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"海浪"},{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err != nil { + t.Errorf("native form with text should pass: %v", err) + } +} + +// TestEstimateBilling_OnlySpecKeys 计费上下文只能包含 spec.* 四个键 +// (duration / resolution / input_image_count / has_input_video): +// ratios 里的 float64 值会被计费引擎连乘,混入非乘数键即资损向量。 +func TestEstimateBilling_OnlySpecKeys(t *testing.T) { + a := &Adaptor{} + ratios := a.EstimateBilling(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"size":"2K"}}`)) + + if len(ratios) != 4 { + t.Fatalf("expected exactly 4 ratio keys, got %d: %v", len(ratios), ratios) + } + if d, ok := ratios["spec.duration"].(float64); !ok || d != 8 { + t.Fatalf("spec.duration = %v (%T), want float64(8)", ratios["spec.duration"], ratios["spec.duration"]) + } + if r := ratios["spec.resolution"]; r != "2K" { + t.Fatalf("spec.resolution = %v, want 2K", r) + } + // 无图请求张数为 0(float64,素材计费方案读) + if c, ok := ratios["spec.input_image_count"].(float64); !ok || c != 0 { + t.Fatalf("spec.input_image_count = %v (%T), want float64(0)", ratios["spec.input_image_count"], ratios["spec.input_image_count"]) + } + // 无视频请求存在性为 false(bool,非乘数键安全) + if has, ok := ratios["spec.has_input_video"].(bool); !ok || has { + t.Fatalf("spec.has_input_video = %v (%T), want bool(false)", ratios["spec.has_input_video"], ratios["spec.has_input_video"]) + } +} + +// TestEstimateBilling_MaterialSignals 素材归并口径(与 buildGenericContent 同源): +// 图片张数:原生形态数 content[] 的 image_url 项;通用形态数 images[] + metadata.image/last_frame/content[]。 +// 输入视频存在性:原生形态查 content[] 的 video_url 项;通用形态查 metadata.content[]。 +func TestEstimateBilling_MaterialSignals(t *testing.T) { + a := &Adaptor{} + + cases := []struct { + name string + body string + wantCount float64 + wantVideo bool + }{ + {"native content 2 images", `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"},{"type":"image_url","image_url":{"url":"https://x/a.png"}},{"type":"image_url","image_url":{"url":"https://x/b.png"}}]}`, 2, false}, + {"native content video", `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"},{"type":"video_url","video_url":{"url":"https://x/v.mp4"}}]}`, 0, true}, + {"generic images + last_frame + content", `{"model":"MiniMax-H3","prompt":"hi","images":["https://x/a.png"],"metadata":{"last_frame":"https://x/b.png","content":[{"type":"image_url","image_url":{"url":"https://x/c.png"}},{"type":"video_url","video_url":{"url":"https://x/v.mp4"}}]}}`, 3, true}, + {"metadata.image only", `{"model":"MiniMax-H3","prompt":"hi","metadata":{"image":"https://x/a.png"}}`, 1, false}, + {"generic text only", `{"model":"MiniMax-H3","prompt":"hi"}`, 0, false}, + } + for _, c := range cases { + ratios := a.EstimateBilling(context.Background(), nil, []byte(c.body)) + if got := ratios["spec.input_image_count"].(float64); got != c.wantCount { + t.Errorf("%s: input_image_count = %v, want %v", c.name, got, c.wantCount) + } + if got := ratios["spec.has_input_video"].(bool); got != c.wantVideo { + t.Errorf("%s: has_input_video = %v, want %v", c.name, got, c.wantVideo) + } + } +} + +// TestBillingBuildConsistency EstimateBilling 与 BuildRequestBody 必须共用同一归一化: +// 同一输入下,计费 spec 值必须与上游实际收到的规格一致(矩阵键逐字符匹配)。 +func TestBillingBuildConsistency(t *testing.T) { + a := &Adaptor{} + inputs := []string{ + `{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"resolution":"1080p"}}`, + `{"model":"MiniMax-H3","prompt":"hi","metadata":{"resolution":"1280x720","duration":4}}`, + `{"model":"MiniMax-H3","prompt":"hi"}`, + `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"2K","duration":15}`, + `{"model":"MiniMax-H3-Max","content":[{"type":"text","text":"hi"}],"resolution":"480P","duration":5}`, + // /v1/videos 入站:size 命名档位(未经四档映射,仅 size 原值透传)走回退归一化 + `{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"2K"}}`, + `{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"768P"}}`, + } + for _, in := range inputs { + ratios := a.EstimateBilling(context.Background(), nil, []byte(in)) + out := buildBody(t, a, testInfo("", "MiniMax-H3", false), in) + + billDur := int(ratios["spec.duration"].(float64)) + if upDur := int(out["duration"].(float64)); billDur != upDur { + t.Errorf("input=%s: billing duration %d != upstream duration %d", in, billDur, upDur) + } + if ratios["spec.resolution"] != out["resolution"] { + t.Errorf("input=%s: billing resolution %v != upstream resolution %v", in, ratios["spec.resolution"], out["resolution"]) + } + } +} + +// TestResolveResolution_SizeFallback metadata.resolution 优先于 metadata.size; +// 仅 size 存在时作为分辨率信号(/v1/videos 的分辨率承载在 size 字段,原值透传)。 +func TestResolveResolution_SizeFallback(t *testing.T) { + cases := []struct { + name string + req map[string]any + want string + }{ + {"resolution wins", map[string]any{"metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "2K"}, + {"size named tier", map[string]any{"metadata": map[string]any{"size": "768P"}}, "768P"}, + {"size lowercase tier", map[string]any{"metadata": map[string]any{"size": "2k"}}, "2K"}, + {"size non-tier passthrough", map[string]any{"metadata": map[string]any{"size": "1536p"}}, "1536p"}, + {"top-level wins all", map[string]any{"resolution": "480P", "metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "480P"}, + {"default for h3 model", map[string]any{"model": "MiniMax-H3"}, "768P"}, + {"default for hailuo model", map[string]any{"model": "MiniMax-Hailuo-2.3"}, "768P"}, + {"default for legacy model", map[string]any{"model": "T2V-01"}, "720P"}, + } + for _, c := range cases { + if got := resolveResolution(c.req); got != c.want { + t.Errorf("%s: resolveResolution = %q, want %q", c.name, got, c.want) + } + } +} + +func TestBuildRequestBody_NativePassthrough(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}, "role": "first_frame"}, + {"type": "video_url", "video_url": {"url": "https://cdn.example/ref.mp4"}, "role": "reference_video"} + ], + "resolution": "2K", + "duration": 10, + "ratio": "16:9", + "aigc_watermark": true, + "callback_url": "https://evil.example/cb" + }` + out := buildBody(t, a, testInfo("", "MiniMax-H3-Upstream", true), body) + + // 模型映射 + content 原样透传 + 顶层参数 + if out["model"] != "MiniMax-H3-Upstream" { + t.Fatalf("upstream model: %v", out["model"]) + } + if out["resolution"] != "2K" || out["duration"] != float64(10) || out["ratio"] != "16:9" { + t.Fatalf("upstream params: resolution=%v duration=%v ratio=%v", out["resolution"], out["duration"], out["ratio"]) + } + if wm, _ := out["aigc_watermark"].(bool); !wm { + t.Fatalf("aigc_watermark should pass through: %v", out["aigc_watermark"]) + } + // callback_url 必须被剥离 + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must be stripped from upstream request") + } + content, ok := out["content"].([]any) + if !ok || len(content) != 3 { + t.Fatalf("expected 3 content items, got %v", out["content"]) + } + img, _ := content[1].(map[string]any) + if img["role"] != "first_frame" { + t.Fatalf("content roles must be preserved: %v", content[1]) + } + vid, _ := content[2].(map[string]any) + if vid["type"] != "video_url" { + t.Fatalf("multimodal reference item must be preserved: %v", content[2]) + } +} + +func TestBuildRequestBody_GenericForm(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "prompt": "小猫对着镜头打哈欠", + "seconds": "8", + "images": ["https://cdn.example/first.png"], + "metadata": {"resolution": "2K", "ratio": "9:16", "last_frame": "https://cdn.example/last.png"} + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["duration"] != float64(8) || out["resolution"] != "2K" || out["ratio"] != "9:16" { + t.Fatalf("mapped params: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"]) + } + content, ok := out["content"].([]any) + if !ok || len(content) != 3 { + t.Fatalf("expected text + first_frame + last_frame, got %v", out["content"]) + } + text, _ := content[0].(map[string]any) + if text["type"] != "text" || text["text"] != "小猫对着镜头打哈欠" { + t.Fatalf("text item: %v", content[0]) + } + first, _ := content[1].(map[string]any) + if first["role"] != "first_frame" { + t.Fatalf("first frame role: %v", content[1]) + } + last, _ := content[2].(map[string]any) + if last["role"] != "last_frame" { + t.Fatalf("last frame role: %v", content[2]) + } +} + +// TestBuildRequestBody_GenericDefaults 纯文生视频缺省值:duration=6、resolution=768P、ratio=16:9 +func TestBuildRequestBody_GenericDefaults(t *testing.T) { + a := &Adaptor{} + out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-H3","prompt":"hi"}`) + + if out["duration"] != float64(6) || out["resolution"] != "768P" || out["ratio"] != "16:9" { + t.Fatalf("defaults: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"]) + } + content, _ := out["content"].([]any) + if len(content) != 1 { + t.Fatalf("expected text-only content, got %v", out["content"]) + } +} + +// TestBuildRequestBody_MetadataContentAppend metadata.content 多模态参考项透传追加 +func TestBuildRequestBody_MetadataContentAppend(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "prompt": "hi", + "metadata": { + "content": [{"type": "audio_url", "audio_url": {"url": "https://cdn.example/vo.mp3"}, "role": "reference_audio"}] + } + }` + out := buildBody(t, a, testInfo("", "", false), body) + content, _ := out["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected text + appended reference item, got %v", out["content"]) + } + audio, _ := content[1].(map[string]any) + if audio["type"] != "audio_url" || audio["role"] != "reference_audio" { + t.Fatalf("appended reference item: %v", content[1]) + } +} + +// httpResp 构造带 JSON body 的 http.Response 桩 +func httpResp(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestDoResponse(t *testing.T) { + a := &Adaptor{} + info := testInfo("", "MiniMax-H3", false) // v2 分派(DoResponse 按模型走 v1/v2 解析) + + // 提交成功 + taskID, _, taskErr := a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":"424010985738629"}`), info) + if taskErr != nil || taskID != "424010985738629" { + t.Fatalf("submit response: id=%q err=%+v", taskID, taskErr) + } + + // 上游错误:OaiError 风格 message 透传 + _, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusBadRequest, `{"error":{"type":"invalid_request_error","message":"resolution not supported"}}`), info) + if taskErr == nil || taskErr.Message != "resolution not supported" || taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("error response: %+v", taskErr) + } + + // 空 task_id + _, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":""}`), info) + if taskErr == nil { + t.Fatal("expected error for empty task id") + } +} + +func TestParseTaskResult(t *testing.T) { + a := &Adaptor{} + + succeeded := `{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}` + info, err := a.ParseTaskResult([]byte(succeeded)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://video.cdn.example/out.mp4" { + t.Fatalf("succeeded: %+v", info) + } + if info.TotalTokens != 273890 || info.PromptTokens != 13500 || info.CompletionTokens != 260390 { + t.Fatalf("usage tokens: %+v", info) + } + if info.ActualCost != 0 { + t.Fatalf("ActualCost must stay 0 (per_second 预扣即终价), got %v", info.ActualCost) + } + + // 素材计量:官方 usage 携带按秒计量/图片张数时透出(素材计费方案的结算依据) + material := `{"task":{"id":"up-2","status":"succeeded","usage":{"input_seconds":8,"output_seconds":6,"input_image_count":8,"input_audio_seconds":12},"resolution":"768P"}}` + info, err = a.ParseTaskResult([]byte(material)) + if err != nil { + t.Fatalf("parse material: %v", err) + } + if info.MaterialUsage == nil { + t.Fatal("MaterialUsage should be exported when usage carries material fields") + } + if info.MaterialUsage.InputVideoSeconds != 8 || info.MaterialUsage.OutputSeconds != 6 || + info.MaterialUsage.InputImageCount != 8 || info.MaterialUsage.InputAudioSeconds != 12 { + t.Fatalf("material usage: %+v", info.MaterialUsage) + } + + // 素材计量全零/缺失:不透出(generic 结算链不受影响) + info, err = a.ParseTaskResult([]byte(succeeded)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.MaterialUsage != nil { + t.Fatalf("token-only usage should not export MaterialUsage, got %+v", info.MaterialUsage) + } + + statuses := map[string]struct { + want common.TaskStatusEnum + reason string + }{ + `{"task":{"status":"queued"}}`: {common.TaskStatusQueued, ""}, + `{"task":{"status":"running"}}`: {common.TaskStatusInProgress, ""}, + `{"task":{"status":"failed","error":{"code":"E1","message":"boom"}}}`: {common.TaskStatusFailure, "boom"}, + `{"task":{"status":"cancelled"}}`: {common.TaskStatusFailure, "task cancelled"}, + } + for body, want := range statuses { + info, err := a.ParseTaskResult([]byte(body)) + if err != nil { + t.Fatalf("parse %s: %v", body, err) + } + if info.Status != want.want { + t.Errorf("%s: status=%v want %v", body, info.Status, want.want) + } + if want.reason != "" && info.FailReason != want.reason { + t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason) + } + if !info.Status.IsTerminal() && (want.want == common.TaskStatusFailure) { + t.Errorf("%s: cancelled/failed must map to terminal state", body) + } + } + + // 非 task 包装形态(防御):未知结构降级为进行中,不报错 + info, err = a.ParseTaskResult([]byte(`{"task_id":"up-1"}`)) + if err != nil || info.Status != common.TaskStatusInProgress { + t.Fatalf("submit-shaped data should degrade to in-progress: %+v err=%v", info, err) + } +} + +func TestFetchTask_URL(t *testing.T) { + var gotPath, gotAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"task":{"id":"up-1","status":"running"}}`)) + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "up-1", "use_proxy": false, "model": "MiniMax-H3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + if gotPath != "/v2/query/video_generation/up-1" { + t.Fatalf("fetch path: %s", gotPath) + } + if gotAuth != "Bearer sk-test" { + t.Fatalf("authorization header: %q", gotAuth) + } +} + +func TestCancelTask(t *testing.T) { + var gotMethod, gotPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"424010985738629","action":"cancelled","status":"cancelled"}`)) + })) + defer upstream.Close() + + result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false) + if taskErr != nil { + t.Fatalf("cancel: %+v", taskErr) + } + if gotMethod != http.MethodDelete || gotPath != "/v2/video_generation/424010985738629" { + t.Fatalf("cancel request: %s %s", gotMethod, gotPath) + } + if result.Action != "cancelled" || result.Status != "cancelled" { + t.Fatalf("cancel result: %+v", result) + } +} + +// TestCancelTask_UpstreamError 上游拒绝(如运行中不可取消):错误码与消息透传 +func TestCancelTask_UpstreamError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","message":"running task cannot be cancelled"}}`)) + })) + defer upstream.Close() + + result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false) + if result != nil || taskErr == nil { + t.Fatalf("expected error, got result=%+v err=%+v", result, taskErr) + } + if taskErr.StatusCode != http.StatusBadRequest || taskErr.Message != "running task cannot be cancelled" { + t.Fatalf("upstream error relay: %+v", taskErr) + } +} + +// ==================== v1(Hailuo 系列)协议 ==================== + +func TestIsV2Model(t *testing.T) { + cases := map[string]bool{ + "MiniMax-H3": true, + "MiniMax-H3-Max": true, + "minimax-h3-anything": true, + "MiniMax-Hailuo-2.3": false, + "MiniMax-Hailuo-02": false, + "T2V-01": false, + "S2V-01": false, + "": false, + } + for model, want := range cases { + if got := IsV2Model(model); got != want { + t.Errorf("IsV2Model(%q) = %v, want %v", model, got, want) + } + } +} + +func TestBuildRequestBody_V1Generic(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-Hailuo-2.3", + "prompt": "一只猫在月光下的屋顶上行走", + "seconds": "10", + "images": ["https://cdn.example/first.png"], + "metadata": {"resolution": "1080P", "last_frame": "https://cdn.example/last.png"} + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["model"] != "MiniMax-Hailuo-2.3" || out["prompt"] != "一只猫在月光下的屋顶上行走" { + t.Fatalf("v1 flat fields: %v", out) + } + if out["duration"] != float64(10) || out["resolution"] != "1080P" { + t.Fatalf("v1 duration/resolution: %v/%v", out["duration"], out["resolution"]) + } + if out["first_frame_image"] != "https://cdn.example/first.png" { + t.Fatalf("first_frame_image from images[0]: %v", out["first_frame_image"]) + } + if out["last_frame_image"] != "https://cdn.example/last.png" { + t.Fatalf("last_frame_image from metadata: %v", out["last_frame_image"]) + } + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must never reach upstream") + } +} + +// TestBuildRequestBody_V1Native v1 官方入站的扁平字段透传(含可选布尔参数) +func TestBuildRequestBody_V1Native(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-Hailuo-02", + "prompt": "电影感运镜", + "first_frame_image": "https://cdn.example/f.png", + "last_frame_image": "https://cdn.example/l.png", + "duration": 10, + "resolution": "768P", + "prompt_optimizer": false, + "fast_pretreatment": true, + "aigc_watermark": true, + "callback_url": "https://evil.example/cb" + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["first_frame_image"] != "https://cdn.example/f.png" || out["last_frame_image"] != "https://cdn.example/l.png" { + t.Fatalf("frame images passthrough: %v", out) + } + if po, _ := out["prompt_optimizer"].(bool); po { + t.Fatalf("prompt_optimizer passthrough: %v", out["prompt_optimizer"]) + } + if fp, _ := out["fast_pretreatment"].(bool); !fp { + t.Fatalf("fast_pretreatment passthrough: %v", out["fast_pretreatment"]) + } + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must never reach upstream") + } + if _, has := out["content"]; has { + t.Fatal("v1 request must not carry content[]") + } +} + +// TestBuildRequestBody_V1DefaultResolution 分辨率缺省按模型默认档:Hailuo 2.x → 768P,旧型号 → 720P +func TestBuildRequestBody_V1DefaultResolution(t *testing.T) { + a := &Adaptor{} + out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-Hailuo-2.3","prompt":"hi"}`) + if out["resolution"] != "768P" { + t.Fatalf("hailuo default resolution: %v", out["resolution"]) + } + out = buildBody(t, a, testInfo("", "", false), `{"model":"T2V-01","prompt":"hi"}`) + if out["resolution"] != "720P" { + t.Fatalf("legacy model default resolution: %v", out["resolution"]) + } +} + +func TestDoResponse_V1(t *testing.T) { + a := &Adaptor{} + info := testInfo("", "MiniMax-Hailuo-2.3", false) + + // 提交成功(含 base_resp 成功信封) + taskID, _, taskErr := a.DoResponse(context.Background(), + httpResp(http.StatusOK, `{"task_id":"v1-up-1","base_resp":{"status_code":0,"status_msg":"success"}}`), info) + if taskErr != nil || taskID != "v1-up-1" { + t.Fatalf("v1 submit: id=%q err=%+v", taskID, taskErr) + } + + // HTTP 200 但 base_resp 业务码非 0:业务失败 + _, _, taskErr = a.DoResponse(context.Background(), + httpResp(http.StatusOK, `{"base_resp":{"status_code":2013,"status_msg":"invalid parameters"}}`), info) + if taskErr == nil || taskErr.Message != "invalid parameters" || taskErr.ErrCode != "2013" { + t.Fatalf("v1 business error: %+v", taskErr) + } +} + +// TestFetchTask_V1_TwoHop v1 查询:Success 后链式二跳 files/retrieve 换 download_url 并合并进响应体 +func TestFetchTask_V1_TwoHop(t *testing.T) { + var queryPath, filePath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/v1/query/video_generation"): + queryPath = r.URL.Path + "?" + r.URL.RawQuery + _, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"base_resp":{"status_code":0}}`)) + case strings.HasPrefix(r.URL.Path, "/v1/files/retrieve"): + filePath = r.URL.Path + "?" + r.URL.RawQuery + _, _ = w.Write([]byte(`{"file":{"file_id":9001,"download_url":"https://cdn.example/out.mp4"},"base_resp":{"status_code":0}}`)) + default: + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + + if queryPath != "/v1/query/video_generation?task_id=v1-up-1" { + t.Fatalf("v1 query path: %s", queryPath) + } + if filePath != "/v1/files/retrieve?file_id=9001" { + t.Fatalf("file retrieve path: %s", filePath) + } + + // 合并后的响应体经 ParseTaskResult 提取 ResultURL + body, _ := io.ReadAll(resp.Body) + info, err := a.ParseTaskResult(body) + if err != nil { + t.Fatalf("parse merged: %v", err) + } + if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://cdn.example/out.mp4" { + t.Fatalf("two-hop result: %+v", info) + } +} + +// TestFetchTask_V1_TwoHopFailure 二跳失败:任务仍按 Success 返回,仅缺 download_url +func TestFetchTask_V1_TwoHopFailure(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/query/video_generation") { + _, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","base_resp":{"status_code":0}}`)) + return + } + // files/retrieve 返回业务错误 + _, _ = w.Write([]byte(`{"file":{"file_id":9001},"base_resp":{"status_code":1001,"status_msg":"request timeout"}}`)) + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + info, err := a.ParseTaskResult(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.Status != common.TaskStatusSuccess { + t.Fatalf("two-hop failure must not change Success status: %+v", info) + } + if info.ResultURL != "" { + t.Fatalf("download_url must be absent on two-hop failure: %q", info.ResultURL) + } +} + +func TestParseTaskResult_V1Statuses(t *testing.T) { + a := &Adaptor{} + cases := map[string]struct { + status common.TaskStatusEnum + reason string + }{ + `{"task_id":"t","status":"Preparing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""}, + `{"task_id":"t","status":"Queueing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""}, + `{"task_id":"t","status":"Processing","base_resp":{"status_code":0}}`: {common.TaskStatusInProgress, ""}, + `{"task_id":"t","status":"Fail","base_resp":{"status_code":1026,"status_msg":"content violates policy"}}`: {common.TaskStatusFailure, "content violates policy"}, + } + for body, want := range cases { + info, err := a.ParseTaskResult([]byte(body)) + if err != nil { + t.Fatalf("parse %s: %v", body, err) + } + if info.Status != want.status { + t.Errorf("%s: status=%v want %v", body, info.Status, want.status) + } + if want.reason != "" && info.FailReason != want.reason { + t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason) + } + } +} diff --git a/relay/taskchannel/minimax/cancel.go b/relay/taskchannel/minimax/cancel.go new file mode 100644 index 0000000..6f8c6a8 --- /dev/null +++ b/relay/taskchannel/minimax/cancel.go @@ -0,0 +1,66 @@ +package minimax + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// 上游任务取消(DELETE /v2/video_generation/{task_id})。 +// 官方语义:仅 queued 态可取消(action=cancelled);running / cancelled 不可操作; +// succeeded / failed 是删除任务记录(action=deleted)——网关只做取消、不做删除, +// 因此调用方必须校验返回的 action 字段,仅 cancelled 视为取消成功。 + +// CancelResult 上游取消响应(DeleteVideoGenerationV2Resp) +type CancelResult struct { + TaskID string `json:"task_id"` + Action string `json:"action"` // cancelled = 取消成功;deleted = 任务已终态被删记录(非取消) + Status string `json:"status"` +} + +// CancelTask 取消上游排队中的任务。 +// 返回的 TaskError 携带上游状态码与错误信息(含上游判定「运行中不可取消」等场景), +// 调用方以本结果为准决定是否向客户端确认取消。 +func CancelTask(baseURL, apiKey, taskID string, useProxy bool) (*CancelResult, *common.TaskError) { + url := fmt.Sprintf("%s/v2/video_generation/%s", strings.TrimRight(baseURL, "/"), taskID) + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "create cancel request failed"} + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := common.NewPooledClient(30, useProxy) + resp, err := client.Do(req) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadGateway, Message: "cancel request failed: " + err.Error()} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read cancel response failed"} + } + + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + var errResp oaiErrorBody + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + } + if msg == "" { + msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode) + } + return nil, &common.TaskError{StatusCode: resp.StatusCode, Message: msg} + } + + var result CancelResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse cancel response failed"} + } + return &result, nil +} diff --git a/relay/taskchannel/minimax/constants.go b/relay/taskchannel/minimax/constants.go new file mode 100644 index 0000000..da6bab4 --- /dev/null +++ b/relay/taskchannel/minimax/constants.go @@ -0,0 +1,21 @@ +package minimax + +// MiniMax 视频生成模型:H3 系列走 v2 协议(content[] 多模态),Hailuo 2.x 主力型号走 v1 扁平协议。 +// 旧型号(T2V/I2V/S2V-01 系列)不进默认清单,但请求传入照样转发(协议分派按模型名,不拦)。 +var ModelList = []string{ + "MiniMax-H3", + "MiniMax-H3-Max", + "MiniMax-Hailuo-2.3", + "MiniMax-Hailuo-2.3-Fast", + "MiniMax-Hailuo-02", +} + +const channelName = "MiniMaxVideo" + +// 分辨率/时长缺省值(与计费估算共用,见 adaptor.go 的 resolveResolution/resolveDuration) +const ( + defaultResolution = "768P" // H3 与 Hailuo 2.x 的默认档 + defaultResolutionV1 = "720P" // T2V/I2V/S2V 等旧型号的默认档 + defaultDurationSecs = 6 + defaultTextOnlyRatio = "16:9" +) diff --git a/relay/taskchannel/minimax/types.go b/relay/taskchannel/minimax/types.go new file mode 100644 index 0000000..28c95a9 --- /dev/null +++ b/relay/taskchannel/minimax/types.go @@ -0,0 +1,51 @@ +package minimax + +// MiniMax v2 视频协议的任务对象与响应结构。 +// 字段定义对齐官方 VideoTask(docs/modeldocs/minimax/video/04-响应对象.md), +// 由适配器解析上游查询响应、relay/handler 层组装查询端点回放响应共用。 + +// VideoTaskContent 任务产物内容(视频任务返回 url,Context-IR 任务返回 prompt) +type VideoTaskContent struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// VideoTaskError 任务错误信息(任务失败时返回) +type VideoTaskError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// VideoTaskUsage 用量:视频任务返回按秒计量字段,H3-Context-IR 任务返回 Token 字段 +type VideoTaskUsage struct { + TotalSeconds int `json:"total_seconds,omitempty"` + InputSeconds int `json:"input_seconds,omitempty"` + OutputSeconds int `json:"output_seconds,omitempty"` + InputImageCount int `json:"input_image_count,omitempty"` + InputAudioSeconds int `json:"input_audio_seconds,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` +} + +// VideoTask 官方 VideoTask 对象(单任务查询与列表接口共用的任务载体) +type VideoTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Error *VideoTaskError `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Content *VideoTaskContent `json:"content,omitempty"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` + Usage *VideoTaskUsage `json:"usage,omitempty"` + Ratio string `json:"ratio,omitempty"` + TaskType string `json:"task_type,omitempty"` + Modality string `json:"modality,omitempty"` +} + +// TaskQueryResponse v2 单任务查询响应(GetVideoGenerationV2Resp,task 外层包装) +type TaskQueryResponse struct { + Task *VideoTask `json:"task"` +} diff --git a/relay/taskchannel/sora/adaptor.go b/relay/taskchannel/sora/adaptor.go index 8a43b30..d63eb4b 100644 --- a/relay/taskchannel/sora/adaptor.go +++ b/relay/taskchannel/sora/adaptor.go @@ -33,14 +33,16 @@ func (a *SoraAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ return nil } -func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + ratios := map[string]any{"base": 1.0} var req map[string]json.RawMessage if json.Unmarshal(body, &req) == nil { - // 按秒数加价 + // per_second 事实键:真实秒数与规格原值(size 为 OpenAI 四档 "宽x高" 格式, + // 矩阵键按同格式配置即可命中) if secs, ok := req["seconds"]; ok { var s string if json.Unmarshal(secs, &s) == nil && s != "" { + ratios["spec.duration"] = parseFloatOrZero(s) if s == "10" || s == "15" || s == "20" { ratios["duration"] = 1.5 } @@ -48,15 +50,31 @@ func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bo } if dur, ok := req["duration"]; ok { var d float64 - if json.Unmarshal(dur, &d) == nil && d > 10 { - ratios["duration"] = 1.5 + if json.Unmarshal(dur, &d) == nil && d > 0 { + ratios["spec.duration"] = d + if d > 10 { + ratios["duration"] = 1.5 + } + } + } + if size, ok := req["size"]; ok { + var s string + if json.Unmarshal(size, &s) == nil && s != "" { + ratios["spec.resolution"] = s } } } return ratios } -func (a *SoraAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +// parseFloatOrZero 秒数字符串转 float(解析失败返回 0,由引擎按缺省时长兜底) +func parseFloatOrZero(s string) float64 { + var f float64 + fmt.Sscanf(s, "%g", &f) + return f +} + +func (a *SoraAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -84,12 +102,12 @@ func (a *SoraAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo return strings.NewReader(string(data)), nil } -func (a *SoraAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *SoraAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -123,7 +141,7 @@ func (a *SoraAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *comm return result.ID, body, nil } -func (a *SoraAdaptor) FetchTask(baseURL, apiKey string, _ []byte) (*http.Response, error) { +func (a *SoraAdaptor) FetchTask(_ context.Context, baseURL, apiKey string, _ []byte) (*http.Response, error) { // Sora 的 FetchTask 需要上游任务 ID,通过 taskData 传入 return nil, fmt.Errorf("sora: use FetchTaskByID instead") } diff --git a/relay/taskchannel/suno/adaptor.go b/relay/taskchannel/suno/adaptor.go index f04803b..224aaf9 100644 --- a/relay/taskchannel/suno/adaptor.go +++ b/relay/taskchannel/suno/adaptor.go @@ -34,11 +34,11 @@ func (a *SunoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ return nil } -func (a *SunoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 { - return map[string]float64{"base": 1.0} +func (a *SunoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]any { + return map[string]any{"base": 1.0} } -func (a *SunoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *SunoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -74,12 +74,12 @@ func (a *SunoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo return strings.NewReader(string(data)), nil } -func (a *SunoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *SunoAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -121,14 +121,14 @@ func (a *SunoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *comm return result.Data, body, nil } -func (a *SunoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *SunoAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var meta struct { UseProxy bool `json:"use_proxy"` } _ = json.Unmarshal(taskData, &meta) url := fmt.Sprintf("%s/suno/fetch", strings.TrimRight(baseURL, "/")) - req, err := http.NewRequest("POST", url, bytes.NewReader(taskData)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(taskData)) if err != nil { return nil, err } diff --git a/relay/taskchannel/volcengine/adaptor.go b/relay/taskchannel/volcengine/adaptor.go index d26772b..08f4c58 100644 --- a/relay/taskchannel/volcengine/adaptor.go +++ b/relay/taskchannel/volcengine/adaptor.go @@ -111,22 +111,26 @@ func (a *VolcengineVideoAdaptor) ValidateRequest(_ context.Context, _ *common.Re return nil } -func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common.RelayInfo, body []byte) map[string]float64 { +func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common.RelayInfo, body []byte) map[string]any { var req map[string]any if err := json.Unmarshal(body, &req); err != nil { - return map[string]float64{} + return map[string]any{} } - ratios := make(map[string]float64) + ratios := make(map[string]any) - // 提取 duration 用于 token 预估(默认 5s) + // 提取 duration:spec.duration 为真实秒数(per_second 矩阵计费用), + // duration 为存量 token 伪装路径信号(语义同为秒,行为不变) duration := extractDuration(req) if duration > 0 { + ratios["spec.duration"] = float64(duration) ratios["duration"] = float64(duration) } - // 提取 resolution 用于 token 预估 + // 提取 resolution:spec.resolution 为规格原值(per_second 矩阵查价键,如 "720p"), + // resolution 为存量硬编码乘数(token 伪装路径用,待模型迁移 per_second 后废弃) if resolution := extractResolution(req); resolution != "" { + ratios["spec.resolution"] = resolution ratios["resolution"] = resolutionMultiplier(resolution) } @@ -140,7 +144,7 @@ func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common return ratios } -func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -261,12 +265,12 @@ func (a *VolcengineVideoAdaptor) BuildRequestBody(_ context.Context, info *commo return strings.NewReader(string(data)), nil } -func (a *VolcengineVideoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *VolcengineVideoAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, url, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) if err != nil { return nil, err } @@ -305,7 +309,7 @@ func (a *VolcengineVideoAdaptor) DoResponse(_ context.Context, resp *http.Respon return result.ID, body, nil } -func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *VolcengineVideoAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -316,7 +320,7 @@ func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []by url := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/api"), data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } 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.go b/relaykit/dto/openai.go index bb862af..0970613 100644 --- a/relaykit/dto/openai.go +++ b/relaykit/dto/openai.go @@ -49,7 +49,7 @@ type ImageConfigDTO struct { // Message OpenAI 消息格式 type Message struct { - Role string `json:"role"` + Role string `json:"role,omitempty"` // 请求/非流式恒有值;流式仅首帧带 role,续帧空值不序列化 Content any `json:"content"` // string 或 []ContentPart Refusal *string `json:"refusal,omitempty"` // 结构化输出拒绝原因 ReasoningContent *string `json:"reasoning_content,omitempty"` // 推理模型(DeepSeek/o1)的思考内容 @@ -103,15 +103,19 @@ type FunctionDef struct { // ToolCall 工具调用 type ToolCall struct { - Index int `json:"index,omitempty"` - ID string `json:"id"` - Type string `json:"type"` + // Index 用指针:流式增量中 index 是客户端归并分片的唯一依据,首个调用的 0 也必须 + // 序列化(int+omitempty 会把 0 吞掉,客户端把续块当成 name 为空的新调用); + // 请求侧与非流式响应不含该字段,留 nil 不序列化。 + Index *int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` Function FunctionCall `json:"function"` } -// FunctionCall 函数调用 +// FunctionCall 函数调用(流式续块只带 arguments,id/type/name 空值不得序列化为 ""; +// arguments 恒带键:codex 等严格 serde 客户端要求首块 "arguments":"" 存在) type FunctionCall struct { - Name string `json:"name"` + Name string `json:"name,omitempty"` Arguments string `json:"arguments"` } 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/reasoning_carry.go b/relaykit/relayconvert/convmeta/reasoning_carry.go new file mode 100644 index 0000000..3bda367 --- /dev/null +++ b/relaykit/relayconvert/convmeta/reasoning_carry.go @@ -0,0 +1,19 @@ +package convmeta + +// ReasoningCarry 宿主可选能力:思考文本跨轮携带(进程内状态)。 +// +// 背景:DeepSeek 等 thinking 上游要求多轮工具调用把上一轮的 reasoning_content +// 随 assistant 历史传回,否则 400("must be passed back to the API")。标准回传 +// 通道是 Responses 的 reasoning 项 encrypted_content(网关自编自解),但部分 +// responses 客户端(ai-sdk 系)重建历史时把 reasoning 项整个剥掉,回传链在客户端 +// 断裂。兜底:网关在响应侧按本轮工具调用 call_id 存下思考文本,下一轮请求转换 +// 按 function_call 的 call_id 捞回。 +// +// 转换器对 Meta 做类型断言获取本接口;未实现时静默跳过(仅靠 encrypted_content 回传)。 +// 宿主端由 *relaycommon.RelayInfo 实现(进程内 TTL 缓存)。 +type ReasoningCarry interface { + // StoreReasoningForCalls 把本轮思考文本按工具调用 call_id 存入(每个 id 一份副本)。 + StoreReasoningForCalls(callIDs []string, reasoning string) + // LookupReasoningForCalls 按 call_id 查思考文本,命中任一即返回;无命中返回空串。 + LookupReasoningForCalls(callIDs []string) string +} 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..bc8ec8a --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_stream.go @@ -0,0 +1,553 @@ +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" +) + +// 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 + contentIndex := 0 + // 输出项索引:message 项固定占 0,其余输出项(reasoning / tool call / server tool) + // 由 itemIdx 从 1 起分配——与 chat→responses 桥接的口径一致 + itemIdx := 1 + 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}) + } + + // 思考内容:必须落成独立的 reasoning 输出项(output_item.added → summary_part.added → + // summary 增量 → done 收口)。把 summary 增量挂在 message 项上是协议违规——OpenAI SDK + // 的流式状态机按 item 类型校验事件归属,错挂会使客户端解析失败并主动断流(client_gone)。 + // Claude 的 interleaved thinking 会出现多个 thinking 块,收口后重开新项。 + var reasoningBuf strings.Builder + reasoningID := "" + reasoningItemIdx := -1 + reasoningItems := make([]map[string]any, 0) // 已收口的 reasoning 项,completed 输出数组用 + closeReasoningItem := func() error { + if reasoningItemIdx < 0 { + return nil + } + text := reasoningBuf.String() + if err := emitEvent("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 + } + if err := emitEvent("response.reasoning_summary_part.done", map[string]any{ + "type": "response.reasoning_summary_part.done", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "part": map[string]any{ + "type": "summary_text", + "text": text, + }, + }, nil); err != nil { + return err + } + item := map[string]any{ + "type": "reasoning", + "id": reasoningID, + "status": "completed", + "summary": []map[string]any{{ + "type": "summary_text", + "text": text, + }}, + } + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 网关自编自解,供下一轮还原思考文本(与 chat→responses 桥接口径一致) + if enc := shared.EncodeReasoningEncryptedContent(text); enc != "" { + item["encrypted_content"] = enc + } + if err := emitEvent("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": reasoningItemIdx, + "item": item, + }, nil); err != nil { + return err + } + reasoningItems = append(reasoningItems, item) + reasoningItemIdx = -1 + reasoningID = "" + reasoningBuf.Reset() + return nil + } + + // closeTextPart 关闭文本 content part(进入工具调用或流结束时调用);message 项固定占 output_index 0 + 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": 0, + "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": 0, + "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": 0, + "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 + return nil + } + + // finish 发送每个工具调用的收尾事件 + response.completed(随帧携带计费用量) + finish := func() error { + if sentCompleted { + return nil + } + if err := closeReasoningItem(); err != nil { + return err + } + if err := closeTextPart(); err != nil { + return err + } + + // 输出数组:文本消息(closeTextPart 已置 sentTextDone,此处等价于有文本才保留, + // 与 chat→responses 桥接的 finalOutput 构建口径一致)+ reasoning 项 + 工具调用 + 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{}, + }}, + }) + } + // reasoning 项:completed 的 output 数组需携带完整项,客户端据此重建下一轮 input 历史 + finalOutput = append(finalOutput, reasoningItems...) + + 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": 0, + "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": 0, + "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": + // 先收口 reasoning 项与文本 content part,再开 function_call 项 + if err := closeReasoningItem(); err != nil { + return err + } + if err := closeTextPart(); err != nil { + return err + } + tc := &claudeToolCallState{id: event.ContentBlock.ID, name: event.ContentBlock.Name} + toolCalls = append(toolCalls, tc) + toolIndexByID[tc.id] = itemIdx + currentTool = tc + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": itemIdx, + "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 + } + itemIdx++ + case "server_tool_use": + // claude 服务端工具:仅 web_search 有 Responses 对应物(web_search_call), + // 其余服务端工具跳过。参数以 input_json_delta 聚合,收尾在 finish 统一冲刷 + if event.ContentBlock.Name != "web_search" { + continue + } + if err := closeReasoningItem(); err != nil { + return err + } + if err := closeTextPart(); err != nil { + return err + } + st := &claudeToolCallState{id: event.ContentBlock.ID, name: event.ContentBlock.Name} + serverToolCalls = append(serverToolCalls, st) + serverToolIndexByID[st.id] = itemIdx + currentServerTool = st + if err := emitEvent("response.output_item.added", map[string]any{ + "type": "response.output_item.added", + "output_index": itemIdx, + "item": map[string]any{ + "type": "web_search_call", + "id": st.id, + "status": "in_progress", + }, + }, nil); err != nil { + return err + } + itemIdx++ + 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 != "" { + // 懒开 reasoning 输出项:首个增量到达时发 output_item.added + + // summary_part.added,后续增量挂在该项的 id / output_index 上 + if reasoningItemIdx < 0 { + reasoningItemIdx = itemIdx + itemIdx++ + reasoningID = fmt.Sprintf("rs_%s_%d", respID, reasoningItemIdx) + if err := emitEvent("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 + } + if err := emitEvent("response.reasoning_summary_part.added", map[string]any{ + "type": "response.reasoning_summary_part.added", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "part": map[string]any{ + "type": "summary_text", + "text": "", + }, + }, nil); err != nil { + return err + } + } + reasoningBuf.WriteString(*event.Delta.Thinking) + if err := emitEvent("response.reasoning_summary_text.delta", map[string]any{ + "type": "response.reasoning_summary_text.delta", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "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..523c5eb --- /dev/null +++ b/relaykit/relayconvert/internal/native_responses/claude_to_responses_test.go @@ -0,0 +1,360 @@ +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", // message 项(固定 output_index 0) + "response.content_part.added", + "response.output_item.added", // reasoning 项(懒开,独立 output_index) + "response.reasoning_summary_part.added", + "response.reasoning_summary_text.delta", // signature_delta 无对应物被忽略 + "response.reasoning_summary_text.done", + "response.reasoning_summary_part.done", + "response.output_item.done", // reasoning 项收口 + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", // message 项收口 + "response.completed", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("事件序列不符\ngot: %v\nwant: %v", got, want) + } + // reasoning 增量必须挂在独立 reasoning 项上(rs_ 前缀 id),不得错挂 message 项—— + // OpenAI SDK 流式状态机按 item 归属校验,错挂会使客户端解析失败断流 + reasoningAdded := responsesDataOf(t, events[3]) + item, _ := reasoningAdded["item"].(map[string]any) + if item == nil || item["type"] != "reasoning" { + t.Fatalf("第二个 output_item.added 必须是 reasoning 项: %+v", reasoningAdded) + } + delta := responsesDataOf(t, events[5]) + if delta["delta"] != "推理中" { + t.Errorf("reasoning delta 不对: %+v", delta) + } + if delta["item_id"] != item["id"] { + t.Errorf("reasoning delta 的 item_id 应指向 reasoning 项: delta=%+v item=%+v", delta, item) + } + if delta["output_index"] != reasoningAdded["output_index"] { + t.Errorf("reasoning delta 的 output_index 应与 reasoning 项一致: delta=%+v added=%+v", delta, reasoningAdded) + } +} 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..e74a76e --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request.go @@ -0,0 +1,352 @@ +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() + } + + // tool_choice 依附于 tools:内置工具被过滤后 tools 可能为空(如客户端只带 web_search), + // 此时残留的 tool_choice 会形成「有 tool_choice 无 tools」的孤儿请求体,OpenAI 同样拒绝。 + // 与 o2c 方向对称处理。 + if claudeReq.ToolChoice != nil && len(openaiReq.Tools) > 0 { + 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..e48b107 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_chat/claude_to_openai_request_test.go @@ -0,0 +1,461 @@ +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) + } +} + +// Claude 侧只带服务端内置工具(web_search)时,函数工具列表为空, +// tool_choice 必须一并丢弃——OpenAI 同样拒绝「有 tool_choice 无 tools」的请求体。 +func TestClaudeToOpenAIRequestConverter_BuiltinOnlyToolsDropsToolChoice(t *testing.T) { + converter := &ClaudeToOpenAIRequestConverter{} + claudeReq := &dto.ClaudeRequest{ + Model: "claude-sonnet-4", + Tools: []dto.ClaudeTool{{Type: "web_search_20250305", Name: "web_search"}}, + ToolChoice: map[string]any{"type": "any"}, + 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) != 0 { + t.Errorf("Tools = %+v, want empty", openaiReq.Tools) + } + if openaiReq.ToolChoice != nil { + t.Errorf("ToolChoice = %v, want nil without tools", openaiReq.ToolChoice) + } + // 搜索能力本身不能丢:走 web_search_options 承载 + if openaiReq.WebSearchOptions == nil { + t.Error("WebSearchOptions is nil, web search capability lost") + } +} 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..fc7993d 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 { @@ -146,7 +146,7 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse( case "tool_use": // tool_use 块开始 toolCall := dto.ToolCall{ - Index: toolCallIdx, + Index: intPtr(toolCallIdx), ID: event.ContentBlock.ID, Type: "function", Function: dto.FunctionCall{ @@ -188,7 +188,7 @@ func (c *ClaudeToOpenAIStreamConverter) ConvertStreamResponse( if event.Delta.PartialJSON != nil && *event.Delta.PartialJSON != "" { if err := chunkWriter(newChunk(dto.Message{ ToolCalls: []dto.ToolCall{{ - Index: toolCallIdx - 1, + Index: intPtr(toolCallIdx - 1), Function: dto.FunctionCall{ Arguments: *event.Delta.PartialJSON, }, diff --git a/relaykit/relayconvert/internal/oai_chat/golden/03_basic_claude_to_oai_response.json b/relaykit/relayconvert/internal/oai_chat/golden/03_basic_claude_to_oai_response.json index 0ee9ae6..3bd9e2e 100644 --- a/relaykit/relayconvert/internal/oai_chat/golden/03_basic_claude_to_oai_response.json +++ b/relaykit/relayconvert/internal/oai_chat/golden/03_basic_claude_to_oai_response.json @@ -22,7 +22,7 @@ "expected_response": { "id": "msg_001", "object": "chat.completion", - "created": 1785291101, + "created": 1789717794, "model": "claude-3-5-sonnet-20241022", "choices": [ { 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..3d6a920 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 消息) @@ -155,10 +153,11 @@ func (c *OpenAIToClaudeRequestConverter) ConvertRequest( claudeReq.System = strings.Join(systemPrompts, "\n\n") } - // 转换 tools - if len(openaiReq.Tools) > 0 { - claudeReq.Tools = shared.MapOpenAIToolsToClaudeTools(openaiReq.Tools) - + // 转换 tools。非 function 类型(如 OpenAI 的 custom 自由格式工具)会被过滤掉, + // 过滤后可能为空——此时 tool_choice 必须一并跳过:tools 空数组本身由 omitempty 省略, + // 但残留的 tool_choice 会形成「有 tool_choice 无 tools」的孤儿请求体,被 Claude 拒绝。 + if claudeTools := shared.MapOpenAIToolsToClaudeTools(openaiReq.Tools); len(claudeTools) > 0 { + claudeReq.Tools = claudeTools // 转换 tool_choice if openaiReq.ToolChoice != nil { switch tc := openaiReq.ToolChoice.(type) { @@ -187,8 +186,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..0d241ad 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,109 @@ 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) + } +} + +// 无参函数工具转换后必须携带 input_schema:Claude 的 custom tool 要求该字段必填。 +func TestOpenAIToClaudeRequestConverter_ToolWithoutParameters(t *testing.T) { + converter := &OpenAIToClaudeRequestConverter{} + maxTokens := 1024 + openaiReq := &dto.GeneralOpenAIRequest{ + Model: "gpt-4", + MaxTokens: &maxTokens, + Messages: []dto.Message{{Role: "user", Content: "现在几点"}}, + Tools: []dto.Tool{{ + Type: "function", + Function: dto.FunctionDef{Name: "get_current_time", Description: "无参数"}, + }}, + } + + result, err := converter.ConvertRequest(context.Background(), &mockMeta{upstreamModel: "claude-3-opus-20240229"}, openaiReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var body struct { + Tools []map[string]any `json:"tools"` + } + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if len(body.Tools) != 1 { + t.Fatalf("got %d tools, want 1: %s", len(body.Tools), data) + } + if _, ok := body.Tools[0]["input_schema"]; !ok { + t.Errorf("input_schema missing in %s", data) + } +} + +// 工具全部为非 function 类型时被过滤空,tools 与 tool_choice 都不得出现在请求体中: +// 只发 tool_choice 会形成孤儿字段,被 Claude 拒绝。 +func TestOpenAIToClaudeRequestConverter_AllToolsFilteredDropsToolChoice(t *testing.T) { + converter := &OpenAIToClaudeRequestConverter{} + maxTokens := 1024 + openaiReq := &dto.GeneralOpenAIRequest{ + Model: "gpt-4", + MaxTokens: &maxTokens, + Messages: []dto.Message{{Role: "user", Content: "hi"}}, + Tools: []dto.Tool{{Type: "custom", Function: dto.FunctionDef{Name: "freeform"}}}, + ToolChoice: "required", + } + + result, err := converter.ConvertRequest(context.Background(), &mockMeta{upstreamModel: "claude-3-opus-20240229"}, openaiReq) + if err != nil { + t.Fatalf("ConvertRequest error: %v", err) + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if _, ok := body["tools"]; ok { + t.Errorf("tools should be absent, got %s", data) + } + if _, ok := body["tool_choice"]; ok { + t.Errorf("tool_choice should be absent without tools, got %s", data) + } +} 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..68a8061 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 { @@ -204,10 +212,14 @@ func (c *GeminiToOpenAIStreamConverter) ConvertStreamResponse( // 函数调用 if part.FunctionCall != nil { argsJSON, _ := json.Marshal(part.FunctionCall.Arguments) + // Gemini 每个函数调用整体到达,但 OpenAI 流式契约要求每个 + // tool_call 增量都带 index(0 也要发),客户端按 index 归并分片 + idx := toolCallIdx if err := chunkWriter(newChunk(dto.Message{ ToolCalls: []dto.ToolCall{{ - ID: fmt.Sprintf("call_%s_%d", responseID, toolCallIdx), - Type: "function", + Index: &idx, + ID: fmt.Sprintf("call_%s_%d", responseID, toolCallIdx), + Type: "function", Function: dto.FunctionCall{ Name: part.FunctionCall.FunctionName, Arguments: string(argsJSON), @@ -239,6 +251,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/golden/03_basic_streaming_gemini_to_oai_stream.json b/relaykit/relayconvert/internal/oai_gemini/golden/03_basic_streaming_gemini_to_oai_stream.json index 13c3098..b931e8b 100644 --- a/relaykit/relayconvert/internal/oai_gemini/golden/03_basic_streaming_gemini_to_oai_stream.json +++ b/relaykit/relayconvert/internal/oai_gemini/golden/03_basic_streaming_gemini_to_oai_stream.json @@ -30,7 +30,6 @@ { "index": 0, "delta": { - "role": "", "content": "Hello!" }, "finish_reason": null @@ -47,7 +46,6 @@ { "index": 0, "delta": { - "role": "", "content": " How are you?" }, "finish_reason": null @@ -64,7 +62,6 @@ { "index": 0, "delta": { - "role": "", "content": null }, "finish_reason": "stop" 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..c1ce69f --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_response.go @@ -0,0 +1,211 @@ +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", + }) + } + + // 思考文本跨轮携带:按本轮工具调用 call_id 存进宿主缓存(与流式路径同口径), + // 供客户端剥掉 reasoning 项时下一轮按 call_id 还原 reasoning_content + if carry, ok := info.(convmeta.ReasoningCarry); ok && + choice.Message.ReasoningContent != nil && *choice.Message.ReasoningContent != "" && + len(choice.Message.ToolCalls) > 0 { + callIDs := make([]string, 0, len(choice.Message.ToolCalls)) + for _, tc := range choice.Message.ToolCalls { + callIDs = append(callIDs, tc.ID) + } + carry.StoreReasoningForCalls(callIDs, *choice.Message.ReasoningContent) + } + } + + // 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..4668017 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream.go @@ -0,0 +1,638 @@ +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/relayconvert/internal/shared" + "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 + reasoningItems := make([]map[string]any, 0) // 已收口的 reasoning 项,completed 输出数组用 + var allReasoning strings.Builder // 全部思考文本累计(跨轮携带 stash 用,不随收口重置) + // closeReasoningItem 收口当前 reasoning 项并重置状态。必须幂等:它在工具调用开始与 + // finish_reason 两处被调,重复对同一 item 发 done 事件会使客户端状态机崩溃 + //(item 已被移出进行中集合,再查为 undefined —— zcode 报 "reading 'summaryParts'")。 + 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 + } + if err := emit("response.reasoning_summary_part.done", map[string]any{ + "type": "response.reasoning_summary_part.done", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "part": map[string]any{ + "type": "summary_text", + "text": text, + }, + }, nil); err != nil { + return err + } + item := map[string]any{ + "type": "reasoning", + "id": reasoningID, + "status": "completed", + "summary": []map[string]any{{ + "type": "summary_text", + "text": text, + }}, + } + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 缺失时 DeepSeek 等 thinking 上游下一轮 400(reasoning_content must be passed back) + if enc := shared.EncodeReasoningEncryptedContent(text); enc != "" { + item["encrypted_content"] = enc + } + if err := emit("response.output_item.done", map[string]any{ + "type": "response.output_item.done", + "output_index": reasoningItemIdx, + "item": item, + }, nil); err != nil { + return err + } + reasoningItems = append(reasoningItems, item) + allReasoning.WriteString(text) + reasoningItemIdx = -1 + reasoningID = "" + reasoningText.Reset() + return 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 + // id 带 output_index 后缀保证多段 reasoning(收口后重开)不撞 id + reasoningID = fmt.Sprintf("rs_%s_%d", respID, reasoningItemIdx) + 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 + } + if err := emit("response.reasoning_summary_part.added", map[string]any{ + "type": "response.reasoning_summary_part.added", + "item_id": reasoningID, + "output_index": reasoningItemIdx, + "summary_index": 0, + "part": map[string]any{ + "type": "summary_text", + "text": "", + }, + }, 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 + // index 缺失时按 0 处理(单工具调用流的兼容兜底) + tcIdx := 0 + if tc.Index != nil { + tcIdx = *tc.Index + } + + // 新 tool call:有 ID 和 name + if callID != "" && tc.Function.Name != "" { + // 前一项若是 reasoning,先收口再开工具项 + if err := closeReasoningItem(); err != nil { + return err + } + // 记录 index → callID 映射,用于后续参数 chunk 的查找 + toolCallIDByIndex[tcIdx] = 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[tcIdx] + } + 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 + } + + // 流在 finish_reason 之前被掐断时 reasoning 项可能仍未收口,此处兜底(幂等,正常路径为 no-op) + if err := closeReasoningItem(); err != nil { + return err + } + + // 思考文本跨轮携带:按本轮工具调用 call_id 存进宿主缓存,下一轮请求转换时 + // 若客户端剥掉了 reasoning 项(ai-sdk 系不回传 encrypted_content),按 call_id 捞回 + // 挂回 assistant.reasoning_content(DeepSeek 等 thinking 上游的多轮硬性要求) + if carry, ok := info.(convmeta.ReasoningCarry); ok && allReasoning.Len() > 0 && len(toolCallIndexByID) > 0 { + callIDs := make([]string, 0, len(toolCallIndexByID)) + for id := range toolCallIndexByID { + callIDs = append(callIDs, id) + } + carry.StoreReasoningForCalls(callIDs, allReasoning.String()) + } + + // 构建 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 项:completed 的 output 数组需携带完整项,客户端据此重建下一轮的 input 历史 + finalOutput = append(finalOutput, reasoningItems...) + 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..10bf146 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/openai_to_responses_stream_test.go @@ -0,0 +1,408 @@ +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_ReasoningStashedByCallID 响应侧把本轮思考文本按工具调用 +// call_id 存进宿主缓存(ReasoningCarry),供客户端剥掉 reasoning 项时下一轮请求捞回。 +func TestOpenAIToResponsesStream_ReasoningStashedByCallID(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":"想一下"}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_s1","type":"function","function":{"name":"shell","arguments":"{}"}}]}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + meta := newCarryMeta() + if _, err := runChatToResponsesStream(t, meta, sse); err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + if got := meta.LookupReasoningForCalls([]string{"call_s1"}); got != "想一下" { + t.Errorf("stash 的思考文本 = %q, want 想一下", got) + } +} + +// TestOpenAIToResponsesStream_ReasoningClosedOnce 复现线上 bug(chn_debug_logs #330): +// reasoning + 工具调用 + finish_reason 的流中,closeReasoningItem 在工具调用开始与 +// finish_reason 两处被调,防重失效时同一 reasoning 项会发两份 done 事件—— +// 客户端状态机对已完成 item 再收口即崩(zcode: "reading 'summaryParts'")。 +func TestOpenAIToResponsesStream_ReasoningClosedOnce(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: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{}"}}]}}]}`, + ``, + `data: {"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + events, err := runChatToResponsesStream(t, &convmeta.Values{OriginModelName: "gpt-4o"}, sse) + if err != nil { + t.Fatalf("ConvertStreamResponse: %v", err) + } + + counts := map[string]int{} + reasoningDone := 0 + for _, ev := range events { + counts[ev.Event]++ + if ev.Event == "response.output_item.done" { + data := eventData(t, ev) + if item, _ := data["item"].(map[string]any); item != nil && item["type"] == "reasoning" { + reasoningDone++ + } + } + } + if counts["response.reasoning_summary_text.done"] != 1 { + t.Errorf("reasoning_summary_text.done 次数 = %d, want 1", counts["response.reasoning_summary_text.done"]) + } + if counts["response.reasoning_summary_part.added"] != 1 || counts["response.reasoning_summary_part.done"] != 1 { + t.Errorf("summary_part added/done = %d/%d, want 1/1", + counts["response.reasoning_summary_part.added"], counts["response.reasoning_summary_part.done"]) + } + if reasoningDone != 1 { + t.Errorf("reasoning 项 output_item.done 次数 = %d, want 1", reasoningDone) + } + // completed 的 output 数组仍需携带 reasoning 项(客户端重建下一轮 input 历史) + last := events[len(events)-1] + if last.Event != "response.completed" { + t.Fatalf("末事件 = %s, want response.completed", last.Event) + } + respObj, _ := eventData(t, last)["response"].(map[string]any) + output, _ := respObj["output"].([]map[string]any) + hasReasoning := false + for _, o := range output { + if o["type"] == "reasoning" { + hasReasoning = true + } + } + if !hasReasoning { + t.Errorf("completed output 缺 reasoning 项: %+v", output) + } +} + +// 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..02b0034 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request.go @@ -0,0 +1,517 @@ +// 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, reasoningCarryOf(info)) + 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"` + // reasoning 项的 encrypted_content:网关自产黑盒(见 shared.EncodeReasoningEncryptedContent), + // ai-sdk 等客户端只回传带该字段的 reasoning 项,summary 可能被剥掉,此为主要回传载体 + EncryptedContent string `json:"encrypted_content,omitempty"` +} + +// r2cTextPart Responses 输出项中文本部件的最小读取形态。 +type r2cTextPart struct { + Text string `json:"text"` +} + +// r2cReasoningText 提取 reasoning 项的思考文本,summary 与 content 两种形态按序拼接; +// 两者皆空时解码网关自产的 encrypted_content(客户端只回传带该字段的项, +// summary 常被剥掉,它是跨轮回传思考文本的主要载体)。 +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) + } + } + } + } + if len(parts) == 0 { + if text, ok := shared.DecodeReasoningEncryptedContent(item.EncryptedContent); ok { + return 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"` +} + +// reasoningCarryOf 提取宿主的思考文本跨轮携带能力,未实现返回 nil。 +func reasoningCarryOf(info convmeta.Meta) convmeta.ReasoningCarry { + if carry, ok := info.(convmeta.ReasoningCarry); ok { + return carry + } + return nil +} + +func r2cConvertInputToMessages(input json.RawMessage, carry convmeta.ReasoningCarry) ([]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 + } + rc := takeReasoning() + if rc == nil && carry != nil { + // 客户端剥掉了 reasoning 项(ai-sdk 系):按 call_id 从宿主缓存捞回响应侧 + // 存下的思考文本(DeepSeek 等 thinking 上游要求 tool_calls 消息带回 reasoning_content) + callIDs := make([]string, 0, len(pendingToolCalls)) + for _, tc := range pendingToolCalls { + callIDs = append(callIDs, tc.ID) + } + if text := carry.LookupReasoningForCalls(callIDs); text != "" { + rc = &text + } + } + // Responses 把同一助手轮拆成 message(文本)+ function_call 两类项,chat 协议的 + // 规范形态是单条 assistant 消息同时带 content + tool_calls。拆成两条连续 assistant + // 消息时,DeepSeek thinking 模式会校验当前轮每条 assistant 消息的 reasoning_content, + // 纯文本那条无处挂 → 400(chn_debug_logs #389),因此紧跟文本消息的工具调用合并进该消息 + if n := len(messages); n > 0 && messages[n-1].Role == "assistant" && + len(messages[n-1].ToolCalls) == 0 && messages[n-1].ToolCallID == "" { + last := &messages[n-1] + last.ToolCalls = pendingToolCalls + if last.ReasoningContent == nil { + last.ReasoningContent = rc + } + pendingToolCalls = nil + return + } + messages = append(messages, dto.Message{ + Role: "assistant", + Content: nil, + ToolCalls: pendingToolCalls, + ReasoningContent: rc, + }) + 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..dc52035 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_request_test.go @@ -0,0 +1,481 @@ +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" + "github.com/qianfree/team-api/relaykit/relayconvert/internal/shared" +) + +// 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) + } + }) + + t.Run("网关自产 encrypted_content 往返回传", func(t *testing.T) { + // 线上链路(chn_debug_logs #374):ai-sdk 只回传带 encrypted_content 的 + // reasoning 项(summary 被剥掉),网关自产黑盒必须能解回 reasoning_content, + // 否则 DeepSeek thinking 模式多轮工具调用 400 + enc := shared.EncodeReasoningEncryptedContent("先查天气") + m := convertResponsesBody(t, &convmeta.Values{}, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"天气"}]},`+ + `{"type":"reasoning","encrypted_content":"`+enc+`"},`+ + `{"type":"function_call","call_id":"call_w","name":"get_weather","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_w","output":"晴"}`+ + `]}`) + 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"] != "先查天气" { + t.Errorf("assistant.reasoning_content = %v, want 先查天气", assistant["reasoning_content"]) + } + }) + + t.Run("客户端剥掉 reasoning 项时按 call_id 从宿主缓存捞回", func(t *testing.T) { // 线上链路(chn_debug_logs #377):ai-sdk 重建历史时把 reasoning 项整个剥掉 + //(连 encrypted_content 一起丢),唯一兜底是响应侧按 call_id 存进宿主缓存、 + // 请求侧按 function_call 的 call_id 捞回 + meta := newCarryMeta() + meta.StoreReasoningForCalls([]string{"call_x"}, "缓存的思考") + m := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"ls"}]},`+ + `{"type":"function_call","call_id":"call_x","name":"shell","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_x","output":"a.go"}`+ + `]}`) + 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"] != "缓存的思考" { + t.Errorf("assistant.reasoning_content = %v, want 缓存的思考", assistant["reasoning_content"]) + } + // input 自带 reasoning 项时优先用客户端回传的,不覆盖 + m2 := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"reasoning","summary":[{"type":"summary_text","text":"客户端回传的思考"}]},`+ + `{"type":"function_call","call_id":"call_x","name":"shell","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_x","output":"a.go"}`+ + `]}`) + msgs2, _ := m2["messages"].([]any) + assistant2 := msgs2[0].(map[string]any) + if assistant2["reasoning_content"] != "客户端回传的思考" { + t.Errorf("客户端回传优先: reasoning_content = %v", assistant2["reasoning_content"]) + } + }) + + t.Run("同轮文本消息与工具调用合并为单条 assistant 消息", func(t *testing.T) { + // 线上 chn_debug_logs #389:Responses 把同一助手轮拆成 message + function_call + // 两类项,转成两条连续 assistant 消息时文本那条无 reasoning_content, + // DeepSeek thinking 校验当前轮每条 assistant 消息 → 400。 + // chat 规范形态是单条 assistant 消息带 content + tool_calls + reasoning_content。 + meta := newCarryMeta() + meta.StoreReasoningForCalls([]string{"call_m"}, "合并的思考") + m := convertResponsesBody(t, meta, `{"model":"deepseek-v4-flash","input":[`+ + `{"type":"message","role":"user","content":[{"type":"input_text","text":"查一下"}]},`+ + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我来查"}]},`+ + `{"type":"function_call","call_id":"call_m","name":"search","arguments":"{}"},`+ + `{"type":"function_call_output","call_id":"call_m","output":"结果"}`+ + `]}`) + msgs, _ := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages = %d 条, want 3(user + 合并 assistant + tool): %v", len(msgs), msgs) + } + assistant := msgs[1].(map[string]any) + if assistant["content"] != "我来查" { + t.Errorf("合并后 content = %v, want 我来查", assistant["content"]) + } + if _, ok := assistant["tool_calls"]; !ok { + t.Error("合并后应带 tool_calls") + } + if assistant["reasoning_content"] != "合并的思考" { + t.Errorf("合并后 reasoning_content = %v, want 合并的思考", assistant["reasoning_content"]) + } + }) +} + +// 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..5f29b7e --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream.go @@ -0,0 +1,362 @@ +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) + // item_id → call_id 映射:function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID) + // 与 call_id 是两个不同的值,而 arguments 增量事件只带 item_id。不做映射会把同一 + // 工具调用拆成两个 index,客户端在无 name 的新 index 上报错 + callIDByItemID := make(map[string]string) + + 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 + } + if itemID := strings.TrimSpace(streamResp.Item.ID); itemID != "" { + callIDByItemID[itemID] = callID + } + 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": + itemID := strings.TrimSpace(streamResp.ItemID) + if itemID == "" { + continue + } + // 增量事件只带 item_id,先映射回 call_id(无 added 事件先行的上游按原值兜底) + callID := callIDByItemID[itemID] + if callID == "" { + callID = itemID + } + 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..ac865e8 --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/responses_to_openai_stream_test.go @@ -0,0 +1,257 @@ +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 == nil || *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_ToolCallItemIDDiffersFromCallID 复现线上 bug(chn_debug_logs #357): +// function_call 项的 item.id(OpenAI 为 fc_xxx、DeepSeek 为 UUID)与 call_id 是两个不同的值, +// 而 arguments 增量事件只带 item_id。不做 item_id→call_id 映射会把同一工具调用拆成两个 +// index(续块无 name),ai-sdk 客户端报 "Expected 'function.name' to be a string"。 +func TestResponsesToOpenAIStream_ToolCallItemIDDiffersFromCallID(t *testing.T) { + sse := strings.Join([]string{ + `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"9abf972b-39ee-431d-b452-1aedef3f6762","call_id":"call_00_abc","name":"Bash","arguments":""}}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"9abf972b-39ee-431d-b452-1aedef3f6762","output_index":0,"delta":"{\"cmd\":"}`, + ``, + `data: {"type":"response.function_call_arguments.delta","item_id":"9abf972b-39ee-431d-b452-1aedef3f6762","output_index":0,"delta":"\"ls\"}"}`, + ``, + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"9abf972b-39ee-431d-b452-1aedef3f6762","call_id":"call_00_abc","name":"Bash","arguments":"{\"cmd\":\"ls\"}"}}`, + ``, + `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) + } + + var args strings.Builder + for _, c := range chunks { + if len(c.Choices) == 0 { + continue + } + for _, tc := range c.Choices[0].Delta.ToolCalls { + // 所有分片必须归并到同一个调用:index 恒 0、id 恒为 call_id + if tc.Index == nil || *tc.Index != 0 { + t.Errorf("tool call index = %v, want 0(同一调用被拆成多个 index)", tc.Index) + } + if tc.ID != "" && tc.ID != "call_00_abc" { + t.Errorf("tool call id = %q, want call_00_abc(item_id 泄漏成了调用 id)", tc.ID) + } + args.WriteString(tc.Function.Arguments) + } + } + // output_item.done 的累计 arguments 与已发增量做差,不得重复下发 + if args.String() != `{"cmd":"ls"}` { + t.Errorf("聚合 arguments = %q, want {\"cmd\":\"ls\"}", args.String()) + } +} + +// 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..4c5b44d --- /dev/null +++ b/relaykit/relayconvert/internal/oai_responses/testmeta_test.go @@ -0,0 +1,54 @@ +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 +} + +// carryMeta 测试用 Meta 实现:附带 ReasoningCarry 能力(对应宿主 RelayInfo 的进程内 TTL 缓存)。 +type carryMeta struct { + *convmeta.Values + store map[string]string +} + +func newCarryMeta() *carryMeta { + return &carryMeta{Values: &convmeta.Values{OriginModelName: "deepseek-v4-flash"}, store: map[string]string{}} +} + +func (m *carryMeta) StoreReasoningForCalls(callIDs []string, reasoning string) { + for _, id := range callIDs { + m.store[id] = reasoning + } +} + +func (m *carryMeta) LookupReasoningForCalls(callIDs []string) string { + for _, id := range callIDs { + if text, ok := m.store[id]; ok { + return text + } + } + return "" +} diff --git a/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go b/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go index f8cf54e..daf4313 100644 --- a/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go +++ b/relaykit/relayconvert/internal/ollama_chat/ollama_chat_test.go @@ -424,7 +424,7 @@ func TestOllamaToOpenAIStreamConverter_ToolCalls(t *testing.T) { t.Fatalf("tool delta = %+v", toolDelta.ToolCalls) } tc := toolDelta.ToolCalls[0] - if tc.Index != 0 || tc.ID != "call_1" || tc.Type != "function" { + if tc.Index == nil || *tc.Index != 0 || tc.ID != "call_1" || tc.Type != "function" { t.Errorf("tool call = %+v", tc) } if tc.Function.Name != "get_weather" || tc.Function.Arguments != `{"city":"Paris"}` { diff --git a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go index 8f4a4af..1a3e308 100644 --- a/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go +++ b/relaykit/relayconvert/internal/ollama_chat/ollama_to_openai_stream.go @@ -178,8 +178,9 @@ func ollamaStreamToolCalls(toolCalls []dto.OllamaToolCall, nextIndex *int) []dto argsBytes = b } } + idx := *nextIndex out = append(out, dto.ToolCall{ - Index: *nextIndex, + Index: &idx, ID: id, Type: "function", Function: dto.FunctionCall{ 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..ad59da2 --- /dev/null +++ b/relaykit/relayconvert/internal/shared/reasoning_responses.go @@ -0,0 +1,116 @@ +package shared + +import ( + "encoding/base64" + "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" + +// reasoningEncryptedPrefix 网关自产 encrypted_content 的明文前缀标记。 +// 解码时校验它,避免把真正的 OpenAI 官方加密黑盒(同为 base64、解出是密文)误当思考文本。 +const reasoningEncryptedPrefix = "tapi-rc1:" + +// EncodeReasoningEncryptedContent 把思考文本编码进 reasoning 项的 encrypted_content。 +// +// 为什么需要它:OpenAI 无状态多轮的设计里,客户端 SDK(ai-sdk/codex 等)只回传带 +// encrypted_content 的 reasoning 项——只有 summary 的项会被丢弃。而 DeepSeek 等 +// thinking 上游要求多轮历史必须带回 reasoning_content,链路断在客户端不回传这一环。 +// 该字段对客户端是不透明黑盒,正好由网关自编自解完成回传。 +func EncodeReasoningEncryptedContent(thinking string) string { + if thinking == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(reasoningEncryptedPrefix + thinking)) +} + +// DecodeReasoningEncryptedContent 解出网关自产 encrypted_content 中的思考文本。 +// 非网关自产(解码失败或无前缀标记,如 OpenAI 官方密文)返回 ok=false。 +func DecodeReasoningEncryptedContent(enc string) (string, bool) { + if enc == "" { + return "", false + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", false + } + s := string(raw) + if !strings.HasPrefix(s, reasoningEncryptedPrefix) { + return "", false + } + return strings.TrimPrefix(s, reasoningEncryptedPrefix), true +} + +// 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, + }}, + // 客户端 SDK 只回传带 encrypted_content 的 reasoning 项(无状态多轮机制), + // 网关据此在下一轮把思考文本还原成 chat 的 reasoning_content + EncryptedContent: EncodeReasoningEncryptedContent(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..5c1b96e 100644 --- a/relaykit/relayconvert/internal/shared/tool_mapper.go +++ b/relaykit/relayconvert/internal/shared/tool_mapper.go @@ -20,9 +20,16 @@ func MapOpenAIToolsToClaudeTools(tools []dto.Tool) []dto.ClaudeTool { Description: tool.Function.Description, } - // 转换参数(JSON Schema) + // 转换参数(JSON Schema)。Claude 要求 custom tool 必须携带 input_schema, + // 而 OpenAI 允许无参函数省略 parameters——缺省时补一个空对象 schema, + // 否则 InputSchema 为 nil 被 omitempty 抹掉,上游直接 400。 if tool.Function.Parameters != nil { claudeTool.InputSchema = tool.Function.Parameters + } else { + claudeTool.InputSchema = map[string]any{ + "type": "object", + "properties": map[string]any{}, + } } claudeTools = append(claudeTools, claudeTool) @@ -32,10 +39,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/tool_mapper_test.go b/relaykit/relayconvert/internal/shared/tool_mapper_test.go index 30d9ce5..13c1f8a 100644 --- a/relaykit/relayconvert/internal/shared/tool_mapper_test.go +++ b/relaykit/relayconvert/internal/shared/tool_mapper_test.go @@ -149,3 +149,62 @@ func TestMapOpenAIToolCallsToClaude(t *testing.T) { t.Errorf("Expected location 'San Francisco', got %v", input["location"]) } } + +// 无参函数(OpenAI 允许省略 parameters)必须补出空对象 schema: +// Claude 的 custom tool 要求 input_schema 必填,缺失会被上游以 400 拒绝。 +func TestMapOpenAIToolsToClaudeTools_NoParameters(t *testing.T) { + tools := []dto.Tool{ + { + Type: "function", + Function: dto.FunctionDef{ + Name: "get_current_time", + Description: "No arguments needed", + }, + }, + } + + result := MapOpenAIToolsToClaudeTools(tools) + + if len(result) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(result)) + } + + if result[0].InputSchema == nil { + t.Fatal("InputSchema is nil, expected empty object schema") + } + + // 序列化后必须带 input_schema 键(omitempty 不能把它抹掉) + data, err := json.Marshal(result[0]) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + schema, ok := decoded["input_schema"].(map[string]any) + if !ok { + t.Fatalf("input_schema missing or wrong type in %s", data) + } + + if schema["type"] != "object" { + t.Errorf("input_schema.type = %v, want object", schema["type"]) + } + + if _, ok := schema["properties"].(map[string]any); !ok { + t.Errorf("input_schema.properties missing in %s", data) + } +} + +// 非 function 类型的工具被过滤后返回空 slice,调用方据此判空。 +func TestMapOpenAIToolsToClaudeTools_AllFiltered(t *testing.T) { + tools := []dto.Tool{ + {Type: "custom", Function: dto.FunctionDef{Name: "freeform"}}, + } + + if result := MapOpenAIToolsToClaudeTools(tools); len(result) != 0 { + t.Errorf("Expected 0 tools, got %d", len(result)) + } +} 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..6f1b9ee --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/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" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..47da228 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/thinking.stream.json @@ -0,0 +1,80 @@ +[ + { + "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..." + }, + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..c6d3a12 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/anthropic_messages_to_openai_chat_completions_stream/tools.stream.json @@ -0,0 +1,142 @@ +[ + { + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "tool_calls": [ + { + "function": { + "arguments": "", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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, + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":" + }, + "index": 0 + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "tool_calls": [ + { + "function": { + "arguments": "\"Shanghai\"}" + }, + "index": 0 + } + ] + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..d7f050a --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/claude_messages_to_oai_responses_stream_resp/thinking.stream.json @@ -0,0 +1,282 @@ +[ + { + "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": { + "item": { + "id": "\u003cscrubbed\u003e", + "status": "in_progress", + "summary": [], + "type": "reasoning" + }, + "output_index": 1, + "type": "response.output_item.added" + }, + "event": "response.output_item.added" + }, + { + "data": { + "item_id": "\u003cscrubbed\u003e", + "output_index": 1, + "part": { + "text": "", + "type": "summary_text" + }, + "summary_index": 0, + "type": "response.reasoning_summary_part.added" + }, + "event": "response.reasoning_summary_part.added" + }, + { + "data": { + "delta": "Let me think...", + "item_id": "\u003cscrubbed\u003e", + "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": "\u003cscrubbed\u003e", + "output_index": 0, + "type": "response.output_text.delta" + }, + "event": "response.output_text.delta" + }, + { + "data": { + "item_id": "\u003cscrubbed\u003e", + "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", + "output_index": 1, + "part": { + "text": "Let me think...", + "type": "summary_text" + }, + "summary_index": 0, + "type": "response.reasoning_summary_part.done" + }, + "event": "response.reasoning_summary_part.done" + }, + { + "data": { + "item": { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", + "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": "\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" + }, + { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", + "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": { + "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..d1ccfb4 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_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" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..836cd4c --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_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": null, + "reasoning_content": "Let me think..." + }, + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..a91a0e2 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/gemini_chat_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,90 @@ +[ + { + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Shanghai\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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 + }, + "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..d6cad57 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_chat_to_oai_responses_stream_resp/thinking.stream.json @@ -0,0 +1,285 @@ +[ + { + "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": { + "item_id": "rs_resp_chatcmpl-up_1", + "output_index": 1, + "part": { + "text": "", + "type": "summary_text" + }, + "summary_index": 0, + "type": "response.reasoning_summary_part.added" + }, + "event": "response.reasoning_summary_part.added" + }, + { + "data": { + "delta": "Let me think...", + "item_id": "rs_resp_chatcmpl-up_1", + "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_1", + "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": "rs_resp_chatcmpl-up_1", + "output_index": 1, + "part": { + "text": "Let me think...", + "type": "summary_text" + }, + "summary_index": 0, + "type": "response.reasoning_summary_part.done" + }, + "event": "response.reasoning_summary_part.done" + }, + { + "data": { + "item": { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", + "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" + }, + { + "encrypted_content": "dGFwaS1yYzE6TGV0IG1lIHRoaW5rLi4u", + "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..fa4bf0e --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/basic.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": "Hello" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..ccce995 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/thinking.stream.json @@ -0,0 +1,93 @@ +[ + { + "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..." + }, + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..a4da5b1 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/oai_responses_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,138 @@ +[ + { + "data": { + "choices": [ + { + "delta": { + "content": null, + "tool_calls": [ + { + "function": { + "arguments": "", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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, + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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, + "tool_calls": [ + { + "function": { + "arguments": "\"Shanghai\"}" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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 + }, + "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..6f4bf55 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/basic.stream.json @@ -0,0 +1,79 @@ +[ + { + "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" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": ", world!" + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..6a73854 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/thinking.stream.json @@ -0,0 +1,80 @@ +[ + { + "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..." + }, + "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." + }, + "finish_reason": null, + "index": 0 + } + ], + "created": 0, + "id": "\u003cscrubbed\u003e", + "model": "origin-model", + "object": "chat.completion.chunk", + "usage": null + } + }, + { + "data": { + "choices": [ + { + "delta": { + "content": null + }, + "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..e276d5b --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/ollama_chat_to_oai_chat_stream_resp/tools.stream.json @@ -0,0 +1,72 @@ +[ + { + "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": "", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Shanghai\"}", + "name": "get_weather" + }, + "id": "\u003cscrubbed\u003e", + "index": 0, + "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 + }, + "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..c068088 --- /dev/null +++ b/relaykit/relayconvert/register/golden/expected/openai_responses_to_openai_chat_completions/full.response.json @@ -0,0 +1,75 @@ +{ + "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": [ + { + "encrypted_content": "dGFwaS1yYzE6Q0FQX01BUktFUl9USElOS0lORyB1c2VyIGFza3Mgd2VhdGhlcg==", + "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__schema-variants.json b/relaykit/relayconvert/register/golden/inputs/claude__schema-variants.json new file mode 100644 index 0000000..583ccbe --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__schema-variants.json @@ -0,0 +1,40 @@ +{ + "model": "origin-model", + "max_tokens": 512, + "messages": [ + {"role": "user", "content": "Search the repo with the where filter."} + ], + "tools": [ + { + "name": "grep", + "description": "search repo", + "input_schema": { + "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"] + } + } + ] +} 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..0d3e351 --- /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_[_],或 reasoning 项的 rs_resp_[_])。 +// 透传自语料的短 ID(msg_up / toolu_1 / call_1 等)不匹配、不清洗,保持可断言。 +var synthesizedStreamID = regexp.MustCompile(`^(?:resp_|msg_|fc_|chatcmpl-)\d{10,}$|^(?:call_|rs_resp_)\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/tests/integration/admin/plan_test.go b/tests/integration/admin/plan_test.go index 0c83c0c..6f6d667 100644 --- a/tests/integration/admin/plan_test.go +++ b/tests/integration/admin/plan_test.go @@ -45,21 +45,19 @@ func TestPlanCRUD(t *testing.T) { detailResp.AssertSuccess(t) var detail struct { - Data struct { - Id int64 `json:"id"` - Name string `json:"name"` - Identifier string `json:"identifier"` - Status string `json:"status"` - } `json:"data"` + Id int64 `json:"id"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Status string `json:"status"` } detailResp.DecodeData(t, &detail) - if detail.Data.Id != planID { - t.Fatalf("expected plan id=%d, got %d", planID, detail.Data.Id) + if detail.Id != planID { + t.Fatalf("expected plan id=%d, got %d", planID, detail.Id) } t.Logf("Plan detail: name=%s, identifier=%s, status=%s", - detail.Data.Name, detail.Data.Identifier, detail.Data.Status) + detail.Name, detail.Identifier, detail.Status) // Update updateResp := client.Put(fmt.Sprintf("/api/admin/plans/%d", planID), map[string]any{ @@ -76,15 +74,13 @@ func TestPlanCRUD(t *testing.T) { verifyResp.AssertSuccess(t) var verifyData struct { - Data struct { - Description string `json:"description"` - } `json:"data"` + Description string `json:"description"` } verifyResp.DecodeData(t, &verifyData) - if verifyData.Data.Description != "updated by integration test" { + if verifyData.Description != "updated by integration test" { t.Fatalf("expected description 'updated by integration test', got %q", - verifyData.Data.Description) + verifyData.Description) } t.Logf("Plan update verified") @@ -105,13 +101,11 @@ func TestPlanToggleRecommend(t *testing.T) { detailResp.AssertSuccess(t) var detail struct { - Data struct { - IsRecommended bool `json:"is_recommended"` - } `json:"data"` + IsRecommended bool `json:"is_recommended"` } detailResp.DecodeData(t, &detail) - t.Logf("Plan %d is_recommended=%v after first toggle", planID, detail.Data.IsRecommended) + t.Logf("Plan %d is_recommended=%v after first toggle", planID, detail.IsRecommended) // Toggle again to revert resp2 := client.Put(fmt.Sprintf("/api/admin/plans/%d/toggle-recommend", planID), nil) diff --git a/tests/integration/admin/tenant_tags_test.go b/tests/integration/admin/tenant_tags_test.go new file mode 100644 index 0000000..375d64c --- /dev/null +++ b/tests/integration/admin/tenant_tags_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +// TestTenantTagsAndRemark 租户标签/备注管理:更新 → 列表/详情可读 → 标签筛选 → 清空。 +// 运行前置:服务已部署包含 000021 迁移(tnt_tenants.tags/remark 列)。 +func TestTenantTagsAndRemark(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // 取一个真实租户:列表第一条 + listResp := client.Get("/api/admin/tenants", map[string]string{"page": "1", "page_size": "1"}) + listResp.AssertSuccess(t) + var listData struct { + List []struct { + ID int64 `json:"id"` + } `json:"list"` + } + listResp.DecodeData(t, &listData) + if len(listData.List) == 0 { + t.Skip("无租户数据,跳过标签/备注集成测试") + } + tenantID := listData.List[0].ID + + marker := fmt.Sprintf("it-tag-%d", tenantID) + + // 1. 更新标签与备注 + updateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": []string{marker, "集成测试"}, + "remark": "集成测试备注", + }) + updateResp.AssertSuccess(t) + + // 2. 详情可读 + getResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + getResp.AssertSuccess(t) + var detail struct { + Tags []string `json:"tags"` + Remark string `json:"remark"` + TotalConsumed string `json:"total_consumed"` + } + getResp.DecodeData(t, &detail) + if len(detail.Tags) != 2 || detail.Tags[0] != marker { + t.Fatalf("tags = %v, want [%s 集成测试]", detail.Tags, marker) + } + if detail.Remark != "集成测试备注" { + t.Fatalf("remark = %q", detail.Remark) + } + if detail.TotalConsumed == "" { + t.Fatal("total_consumed 应返回 decimal 字符串(无消费时为 \"0\")") + } + + // 3. 标签精确筛选能命中 + filterResp := client.Get("/api/admin/tenants", map[string]string{ + "page": "1", "page_size": "20", "tag": marker, + }) + filterResp.AssertSuccess(t) + var filterData struct { + List []struct { + ID int64 `json:"id"` + } `json:"list"` + } + filterResp.DecodeData(t, &filterData) + if len(filterData.List) != 1 || filterData.List[0].ID != tenantID { + t.Fatalf("tag filter should hit tenant %d exactly, got %v", tenantID, filterData.List) + } + + // 4. 标签数量超限被拒(11 个) + tooMany := make([]string, 11) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("tag-%d", i) + } + badResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": tooMany, + }) + if badResp.Code == 0 { + t.Fatal("11 个标签应被拒绝") + } + + // 5. 清空标签/备注(空数组语义) + clearResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": []string{}, "remark": "", + }) + clearResp.AssertSuccess(t) + getResp2 := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + getResp2.AssertSuccess(t) + var detail2 struct { + Tags []string `json:"tags"` + Remark string `json:"remark"` + } + getResp2.DecodeData(t, &detail2) + if len(detail2.Tags) != 0 || detail2.Remark != "" { + t.Fatalf("清空后 tags=%v remark=%q", detail2.Tags, detail2.Remark) + } +} diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index bea081e..00527b1 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -1,12 +1,20 @@ + + + + + + diff --git a/web/admin/src/components/PricingTimeSegmentsEditor.vue b/web/admin/src/components/PricingTimeSegmentsEditor.vue new file mode 100644 index 0000000..77f8496 --- /dev/null +++ b/web/admin/src/components/PricingTimeSegmentsEditor.vue @@ -0,0 +1,412 @@ + + + + + + + diff --git a/web/admin/src/components/pricingSchemeEditors.ts b/web/admin/src/components/pricingSchemeEditors.ts new file mode 100644 index 0000000..786e281 --- /dev/null +++ b/web/admin/src/components/pricingSchemeEditors.ts @@ -0,0 +1,41 @@ +import type { Component } from 'vue' +import MinimaxMaterialEditor from './pricingSchemes/MinimaxMaterialEditor.vue' + +// 计费方案 → 定价编辑器组件映射(特殊计费方案的定价表单在此登记)。 +// 后端 pricing JSONB 顶层 scheme 键声明方案名,ModelPricingModal 据此分发内容区; +// 空名/未登记的方案回落内置通用表单。自定义方案建议 custom: 前缀命名,避免与内置方案撞名。 +// +// 编辑器契约(新增方案组件只需满足此契约并在下方登记一行): +// props: { modelId: number, schemeConfig: any } // schemeConfig = 库内已有配置初值(可为 null) +// expose: save(): Promise // 校验 + PUT 保存;失败自行 Message 提示并返回 false +// 保存成功后的提示与关弹窗由 ModelPricingModal 外壳统一处理,组件无需 emit +const schemeEditors: Record = { + // MiniMax-H3 素材计费(输出按秒 + 输入图片/视频素材) + 'custom:minimax-material': MinimaxMaterialEditor, +} + +// 特殊计费方案选项(新增方案在此登记一行即出现在定价面板的模式下拉中)。 +// modelPrefixes 为模型编码前缀白名单(大小写不敏感):仅命中的模型才显示该特殊计费选项, +// 其余模型只有通用计费,防止把专属方案误配到不适用的模型上。 +export interface SchemeEditorOption { + label: string + value: string + modelPrefixes: string[] +} + +export const schemeEditorOptions: SchemeEditorOption[] = [ + { label: '素材计费(MiniMax)', value: 'custom:minimax-material', modelPrefixes: ['MiniMax-H3'] }, +] + +// availableSchemeOptions 按模型编码过滤可用的特殊计费选项 +export function availableSchemeOptions(modelIdStr?: string | null): SchemeEditorOption[] { + if (!modelIdStr) return [] + const lower = modelIdStr.toLowerCase() + return schemeEditorOptions.filter((o) => o.modelPrefixes.some((p) => lower.startsWith(p.toLowerCase()))) +} + +// resolvePricingSchemeEditor 按方案名取编辑器组件;null = 使用通用表单 +export function resolvePricingSchemeEditor(scheme?: string | null): Component | null { + if (!scheme) return null + return schemeEditors[scheme] ?? null +} diff --git a/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue new file mode 100644 index 0000000..15d2ac1 --- /dev/null +++ b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/web/admin/src/composables/useCurrency.ts b/web/admin/src/composables/useCurrency.ts index 4e2b18d..46f02be 100644 --- a/web/admin/src/composables/useCurrency.ts +++ b/web/admin/src/composables/useCurrency.ts @@ -26,6 +26,12 @@ export const cnyToUsd = computed(() => { return r > 0 ? r : FALLBACK_CNY_TO_USD }) +/** + * 全站显示货币符号(= 本位币符号),供模板直接插值。 + * 仅用于 bil_ 记账层金额与已折算为显示币种的金额;订单层原始 CNY 金额请写死 ¥。 + */ +export const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$')) + export interface FormatMoneyOptions { /** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */ source?: CurrencyCode @@ -48,7 +54,7 @@ export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): stri if (source === 'CNY' && display === 'USD') { num = num * cnyToUsd.value } - const symbol = display === 'CNY' ? '¥' : '$' + const symbol = currencySymbol.value const p = precision ?? (display === 'CNY' ? 2 : 6) const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : '' // 先 toFixed 确保精度,再用 Number 去掉末尾的 0 diff --git a/web/admin/src/composables/usePolling.ts b/web/admin/src/composables/usePolling.ts new file mode 100644 index 0000000..c25798d --- /dev/null +++ b/web/admin/src/composables/usePolling.ts @@ -0,0 +1,105 @@ +/** + * 页面可见性感知的轮询工具 + * + * 后台标签页的轮询请求既浪费带宽也无展示意义,浏览器自身的定时器节流 + * 又不可靠(部分平台放宽到每分钟一次,仍会产生请求)。统一改由 + * visibilitychange 显式控制:页面隐藏时停止排队(隐藏期间零请求), + * 恢复可见时若已超过轮询间隔立即补拉一次,未到则按剩余时间续排。 + * + * 内部用递归 setTimeout 而非 setInterval:上一次回调完成后才排下一次, + * 慢响应下不会堆叠请求。每个调用点创建独立实例,与组件生命周期无关, + * 组件外(模块级单例)也可使用。 + */ + +export interface PollerOptions { + /** start() 时是否立即执行一次回调。默认 false,与 setInterval「先等一个完整间隔」的语义一致 */ + immediate?: boolean +} + +export interface Poller { + /** 启动轮询。幂等:已运行时调用无效果 */ + start(): void + /** 停止轮询并移除可见性监听。幂等,可再次 start() 重新开始完整间隔 */ + stop(): void +} + +export function createPoller( + callback: () => void | Promise, + intervalMs: number, + options?: PollerOptions, +): Poller { + /** 是否处于运行中(start 后 stop 前) */ + let active = false + /** 回调是否执行中,防重入 */ + let inFlight = false + /** 上次回调的执行起点,兼作调度锚点(计算剩余等待时间) */ + let lastRunAt = 0 + let timer: ReturnType | null = null + + function clearTimer(): void { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + function run(): void { + // 防重入:可见性事件与到期定时器可能同时触发 + if (inFlight) return + // 隐藏态兜底:定时器到期与 visibilitychange 存在事件顺序竞态,执行前再判一次 + if (document.hidden) return + inFlight = true + lastRunAt = Date.now() + // 回调异常吞掉,保证后续调度不中断(调用点自身已有 try/catch,这里是双保险) + Promise.resolve(callback()) + .catch(() => {}) + .finally(() => { + inFlight = false + if (active) schedule() + }) + } + + /** 唯一的排队入口:所有续排路径都经过这里,保证同一时刻至多一个待触发定时器 */ + function schedule(): void { + if (!active || document.hidden) return + clearTimer() + const remaining = intervalMs - (Date.now() - lastRunAt) + if (remaining <= 0) { + run() + return + } + timer = setTimeout(run, remaining) + } + + function onVisibilityChange(): void { + if (!active) return + if (document.hidden) { + // 隐藏:停止排队。在途请求自然完成,完成后发现隐藏态不会再续排 + clearTimer() + } else { + // 恢复可见:已超间隔则立即补拉(remaining <= 0 走 run),否则按剩余时间续排 + schedule() + } + } + + function start(): void { + if (active) return + active = true + // 锚点重置:stop 后再 start 视为全新周期,首个回调在完整间隔之后 + lastRunAt = Date.now() + document.addEventListener('visibilitychange', onVisibilityChange) + if (options?.immediate) { + run() + } else { + schedule() + } + } + + function stop(): void { + active = false + clearTimer() + document.removeEventListener('visibilitychange', onVisibilityChange) + } + + return { start, stop } +} 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/constants/vendor.ts b/web/admin/src/constants/vendor.ts new file mode 100644 index 0000000..2c8fcac --- /dev/null +++ b/web/admin/src/constants/vendor.ts @@ -0,0 +1,61 @@ +// 模型研发厂商共享常量(管理后台模型管理列表/筛选/表单共用) +// value 与后端 mdl_models.vendor 枚举一一对应(api/admin/v1/model.go 的 v:"in:" 校验、 +// internal/logic/admin/model.go 的 validModelVendors)。 +// 新增厂商时需同步:后端枚举校验、租户端 web/tenant/src/constants/vendor.ts、本文件。 +// 注意与渠道供应商类型(constants/channel.ts,chn_channels.type)是两个维度:厂商 = 模型的研发公司, +// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。 + +export const vendorOptions: { label: string; value: string }[] = [ + { label: 'OpenAI', value: 'openai' }, + { label: 'Anthropic', value: 'anthropic' }, + { label: 'Google', value: 'google' }, + { label: 'xAI', value: 'xai' }, + { label: 'Mistral', value: 'mistral' }, + { label: 'Cohere', value: 'cohere' }, + { label: 'Meta', value: 'meta' }, + { label: '阿里巴巴', value: 'alibaba' }, + { label: '字节跳动', value: 'bytedance' }, + { label: '深度求索', value: 'deepseek' }, + { label: '智谱AI', value: 'zhipu' }, + { label: '月之暗面', value: 'moonshot' }, + { label: 'MiniMax', value: 'minimax' }, + { label: '百度', value: 'baidu' }, + { label: '腾讯', value: 'tencent' }, + { label: '科大讯飞', value: 'xunfei' }, + { label: '快手', value: 'kuaishou' }, + { label: 'Midjourney', value: 'midjourney' }, + { label: 'Suno', value: 'suno' }, +] + +// value -> label 映射,表格列展示用 +export const vendorLabelMap: Record = {} +vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label }) + +// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设) +export const vendorTagColor: Record = { + openai: 'green', + anthropic: 'orange', + google: 'arcoblue', + xai: 'gray', + mistral: 'orangered', + cohere: 'cyan', + meta: 'blue', + alibaba: 'purple', // 图标用通义千问 mark(品牌紫),Tag 色随之取紫 + bytedance: 'arcoblue', + deepseek: 'blue', + zhipu: 'purple', + moonshot: 'gray', + minimax: 'red', + baidu: 'blue', + tencent: 'cyan', + xunfei: 'arcoblue', + kuaishou: 'orangered', + midjourney: 'gray', + suno: 'purple', +} + +// Arco Select 的 allow-search 自定义过滤:小写包含匹配 +export function filterVendorOption(inputValue: string, option: { label: string; value: string }) { + const input = inputValue.toLowerCase() + return option.label.toLowerCase().includes(input) +} diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 0de17d8..7ec54df 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -14,11 +14,11 @@ console.log( 'background: #115e59; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;', ) console.log( - '%cAGPL v3.0 开源协议', + '%cAGPL-3.0-or-later 开源协议', 'color: #666;', ) console.log( - '%cCopyright © 2025-2026 Team-API Contributors', + '%cCopyright © 2026 qianfree', 'color: #666;', ) console.log( diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 76e9dc8..d0c5d44 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -22,13 +22,21 @@ const adminRoutes: RouteRecordRaw[] = [ path: '', name: 'AdminDashboard', component: () => import('@/views/DashboardPage.vue'), - meta: { title: '仪表盘' }, + meta: { title: '仪表盘', perm: 'dashboard:view' }, }, { path: 'workbench', name: 'AdminWorkbench', component: () => import('@/views/WorkbenchPage.vue'), - meta: { title: '工作台' }, + meta: { title: '工作台', perm: 'dashboard:view' }, + }, + { + // 权限不足的落地页:路由守卫拦截越权访问后跳到这里。 + // 接口层本就会 403,但直接给一个空白页加一片报错的体验很差。 + path: '403', + name: 'AdminForbidden', + component: () => import('@/views/ForbiddenPage.vue'), + meta: { title: '无权访问' }, }, { path: 'profile', @@ -40,270 +48,276 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'users', name: 'AdminUsers', component: () => import('@/views/system/UsersPage.vue'), - meta: { title: '用户管理' }, + meta: { title: '用户管理', perm: 'user:view' }, }, { path: 'plugins', name: 'AdminPlugins', component: () => import('@/views/system/PluginsPage.vue'), - meta: { title: '插件管理' }, + meta: { title: '插件管理', perm: 'system:plugin' }, }, { path: 'models', name: 'AdminModels', component: () => import('@/views/ai/ModelsPage.vue'), - meta: { title: '模型管理' }, + meta: { title: '模型管理', perm: 'model:view' }, }, { path: 'model-groups', name: 'AdminModelGroups', component: () => import('@/views/ai/ModelGroupsPage.vue'), - meta: { title: '模型分组' }, + meta: { title: '模型分组', perm: 'model:view' }, }, { path: 'channels', name: 'AdminChannels', component: () => import('@/views/ai/ChannelsPage.vue'), - meta: { title: '渠道管理' }, + meta: { title: '渠道管理', perm: 'channel:view' }, }, { path: 'channels/:id', name: 'AdminChannelDetail', component: () => import('@/views/ai/ChannelDetailPage.vue'), - meta: { title: '渠道详情' }, + meta: { title: '渠道详情', perm: 'channel:view' }, }, { path: 'usage-logs', name: 'AdminUsageLogs', component: () => import('@/views/tenant/UsageLogsPage.vue'), - meta: { title: '用量日志' }, + meta: { title: '用量日志', perm: 'billing:view' }, }, { path: 'transactions', name: 'AdminTransactions', component: () => import('@/views/finance/TransactionsPage.vue'), - meta: { title: '交易流水' }, + meta: { title: '交易流水', perm: 'billing:view' }, }, { path: 'orders', name: 'AdminOrders', component: () => import('@/views/finance/OrdersPage.vue'), - meta: { title: '订单管理' }, + meta: { title: '订单管理', perm: 'order:view' }, }, { // 旧"支付设置"独立页已并入系统设置(payment 分类),URL 保留重定向兼容 path: 'payment-settings', redirect: { name: 'AdminSettings' }, }, + { + path: 'payment-channels', + name: 'AdminPaymentChannels', + component: () => import('@/views/finance/PaymentChannelsPage.vue'), + meta: { title: '支付渠道', perm: 'payment:view' }, + }, { path: 'redemptions', name: 'AdminRedemptions', component: () => import('@/views/finance/RedemptionsPage.vue'), - meta: { title: '兑换码管理' }, + meta: { title: '兑换码管理', perm: 'redemption:view' }, }, { path: 'promo-codes', name: 'AdminPromoCodes', component: () => import('@/views/finance/PromoCodesPage.vue'), - meta: { title: '优惠码管理' }, + meta: { title: '优惠码管理', perm: 'promo:view' }, }, { path: 'sessions', name: 'AdminSessions', component: () => import('@/views/security/SessionsPage.vue'), - meta: { title: '会话管理' }, + meta: { title: '会话管理', perm: 'user:edit' }, }, { path: 'permissions', name: 'AdminPermissions', component: () => import('@/views/security/PermissionsPage.vue'), - meta: { title: '权限管理' }, + meta: { title: '角色权限', superOnly: true }, }, { path: 'login-history', name: 'AdminLoginHistory', component: () => import('@/views/security/LoginHistoryPage.vue'), - meta: { title: '登录历史' }, + meta: { title: '登录历史', perm: 'audit:view' }, }, { path: 'tenant-login-history', name: 'AdminTenantLoginHistory', component: () => import('@/views/security/TenantLoginHistoryPage.vue'), - meta: { title: '租户登录历史' }, + meta: { title: '租户登录历史', perm: 'audit:view' }, }, { path: 'task-logs', name: 'AdminTaskLogs', component: () => import('@/views/ai/TaskLogsPage.vue'), - meta: { title: '任务日志' }, + meta: { title: '任务日志', perm: 'task:view' }, }, { path: 'tenants', name: 'AdminTenants', component: () => import('@/views/tenant/TenantsPage.vue'), - meta: { title: '租户列表' }, + meta: { title: '租户列表', perm: 'tenant:view' }, }, { path: 'tenants/:id', name: 'AdminTenantDetail', component: () => import('@/views/tenant/TenantDetailPage.vue'), - meta: { title: '租户详情' }, + meta: { title: '租户详情', perm: 'tenant:view' }, }, { path: 'tenant-levels', name: 'AdminTenantLevels', component: () => import('@/views/tenant/TenantLevelsPage.vue'), - meta: { title: '租户级别' }, + meta: { title: '租户级别', perm: 'tenant:view' }, }, { path: 'members', name: 'AdminMembers', component: () => import('@/views/tenant/MembersPage.vue'), - meta: { title: '成员列表' }, + meta: { title: '成员列表', perm: 'member:view' }, }, { path: 'audit', name: 'AdminAudit', component: () => import('@/views/security/AuditPage.vue'), - meta: { title: '操作日志' }, + meta: { title: '操作日志', perm: 'audit:view' }, }, { path: 'request-audit-logs', name: 'AdminRequestAuditLogs', component: () => import('@/views/security/RequestAuditLogsPage.vue'), - meta: { title: '请求审计日志' }, + meta: { title: '请求审计日志', perm: 'audit:view' }, }, { path: 'notification-templates', name: 'AdminNotificationTemplates', component: () => import('@/views/operation/NotificationTemplatesPage.vue'), - meta: { title: '通知模板' }, + meta: { title: '通知模板', perm: 'operation:view' }, }, { path: 'messages', name: 'AdminMessages', component: () => import('@/views/operation/MessagesPage.vue'), - meta: { title: '消息管理' }, + meta: { title: '消息管理', perm: 'operation:view' }, }, { path: 'email-logs', name: 'AdminEmailLogs', component: () => import('@/views/operation/EmailLogsPage.vue'), - meta: { title: '邮件发送记录' }, + meta: { title: '邮件发送记录', perm: 'operation:view' }, }, { path: 'announcements', name: 'AdminAnnouncements', component: () => import('@/views/operation/AnnouncementsPage.vue'), - meta: { title: '公告管理' }, + meta: { title: '公告管理', perm: 'operation:view' }, }, { path: 'tickets', name: 'AdminTickets', component: () => import('@/views/operation/TicketsPage.vue'), - meta: { title: '工单管理' }, + meta: { title: '工单管理', perm: 'support:view' }, }, { path: 'feedback', name: 'AdminFeedback', component: () => import('@/views/operation/FeedbackPage.vue'), - meta: { title: '反馈管理' }, + meta: { title: '反馈管理', perm: 'support:view' }, }, { path: 'settings', name: 'AdminSettings', component: () => import('@/views/system/SettingsPage.vue'), - meta: { title: '系统设置' }, + meta: { title: '系统设置', perm: 'system:view' }, }, { path: 'files', name: 'AdminFiles', component: () => import('@/views/system/FilesPage.vue'), - meta: { title: '文件管理' }, + meta: { title: '文件管理', perm: 'file:view' }, }, { path: 'help-categories', name: 'AdminHelpCategories', component: () => import('@/views/system/HelpCategoriesPage.vue'), - meta: { title: '帮助分类' }, + meta: { title: '帮助分类', perm: 'support:view' }, }, { path: 'help-articles', name: 'AdminHelpArticles', component: () => import('@/views/system/HelpArticlesPage.vue'), - meta: { title: '帮助文章' }, + meta: { title: '帮助文章', perm: 'support:view' }, }, { path: 'changelogs', name: 'AdminChangelogs', component: () => import('@/views/operation/ChangelogsPage.vue'), - meta: { title: '更新日志' }, + meta: { title: '更新日志', perm: 'operation:view' }, }, { path: 'agreements', name: 'AdminAgreements', component: () => import('@/views/operation/AgreementsPage.vue'), - meta: { title: '用户协议' }, + meta: { title: '用户协议', perm: 'system:view' }, }, { path: 'realtime', name: 'AdminRealtimeMonitor', component: () => import('@/views/monitor/RealtimeMonitorPage.vue'), - meta: { title: '实时监控面板' }, + meta: { title: '实时监控面板', perm: 'monitor:view' }, }, { path: 'monitor', name: 'AdminMonitor', component: () => import('@/views/monitor/MonitorPage.vue'), - meta: { title: '系统监控' }, + meta: { title: '系统监控', perm: 'monitor:view' }, }, { path: 'traffic-flow', name: 'AdminTrafficFlow', component: () => import('@/views/monitor/TrafficFlowPage.vue'), - meta: { title: '流量流向' }, + meta: { title: '流量流向', perm: 'monitor:view' }, }, { path: 'model-performance', name: 'AdminModelPerformance', component: () => import('@/views/monitor/ModelPerformancePage.vue'), - meta: { title: '模型性能' }, + meta: { title: '模型性能', perm: 'monitor:view' }, }, { path: 'alert-rules', name: 'AdminAlertRules', component: () => import('@/views/monitor/AlertRulesPage.vue'), - meta: { title: '告警规则' }, + meta: { title: '告警规则', perm: 'monitor:view' }, }, { path: 'alert-events', name: 'AdminAlertEvents', component: () => import('@/views/monitor/AlertEventsPage.vue'), - meta: { title: '告警记录' }, + meta: { title: '告警记录', perm: 'monitor:view' }, }, { path: 'error-logs', name: 'AdminErrorLogs', component: () => import('@/views/monitor/ErrorLogsPage.vue'), - meta: { title: '系统错误日志' }, + meta: { title: '系统错误日志', perm: 'monitor:view' }, }, { path: 'channel-errors', name: 'AdminChannelErrors', component: () => import('@/views/monitor/ChannelErrorsPage.vue'), - meta: { title: '渠道错误监控' }, + meta: { title: '渠道错误监控', perm: 'monitor:view' }, }, { path: 'content-filter-logs', name: 'AdminContentFilterLogs', component: () => import('@/views/monitor/ContentFilterLogsPage.vue'), - meta: { title: '拦截日志' }, + meta: { title: '拦截日志', perm: 'audit:view' }, }, { path: 'cron-jobs', name: 'AdminCronJobs', component: () => import('@/views/monitor/CronJobsPage.vue'), - meta: { title: '定时任务' }, + meta: { title: '定时任务', perm: 'system:view' }, }, ], }, diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 3f56125..ebf1c0e 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' +import { hasPermission, isSuperAdmin } from '@/utils/permission' import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { usePublicSettings } from '@/composables/usePublicSettings' @@ -45,6 +46,10 @@ async function checkSetupStatus(): Promise { return systemInitialized === true } +// 每次页面加载只向服务端同步一次权限:守卫在每次路由跳转都会执行, +// 不加这道闸门会让每次点菜单都多打一个请求。 +let permissionsSynced = false + const { siteName, fetchSiteName } = useSiteName() const { fetchSettings: fetchPublicSettings } = usePublicSettings() const { start, done } = useTopProgress() @@ -87,6 +92,25 @@ router.beforeEach(async (to) => { return { name: 'AdminLogin', query: { redirect: to.fullPath } } } + // 本次会话首次进入受保护页面时,从服务端刷新一次有效权限。 + // 权限只在登录响应里下发过一次,之后由 localStorage 恢复:管理员改了某人的权限, + // 对方不重新登录就会一直按旧权限渲染菜单。这里补上刷新。 + // 注意 await:权限到位后再做下面的 meta.perm 判断,否则首屏可能被误判为越权。 + if (!permissionsSynced) { + permissionsSynced = true + await authStore.fetchMe() + } + + // 页面级权限校验。菜单已按权限过滤,这里拦的是直接输 URL 的情况。 + // 接口层本就会 403,但没有这道守卫,用户会先看到一个空白页再看到一片报错。 + if (to.meta.superOnly && !isSuperAdmin()) { + return { name: 'AdminForbidden' } + } + const requiredPerm = to.meta.perm as string | undefined + if (requiredPerm && !hasPermission(requiredPerm)) { + return { name: 'AdminForbidden' } + } + return true }) diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index 87d9318..bde3448 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -10,6 +10,13 @@ export interface AdminUser { role: string } +export interface AdminRoleBrief { + id: number + code: string + name: string + is_enabled: boolean +} + export interface PendingAgreement { id: number code: string @@ -23,6 +30,7 @@ interface LoginResponse { expires_at: number user: AdminUser permissions: string[] + roles?: AdminRoleBrief[] pending_agreements?: PendingAgreement[] } @@ -34,6 +42,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const expiresAt = ref(null) const user = ref(null) const permissions = ref([]) + const roles = ref([]) const pendingAgreements = ref([]) const rememberMe = ref(getRememberMe()) @@ -48,6 +57,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: expiresAt.value, user: user.value, permissions: permissions.value, + roles: roles.value, } localStorage.setItem(STORE_KEY, JSON.stringify(data)) } @@ -62,12 +72,17 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: number | null user: AdminUser | null permissions: string[] + roles?: AdminRoleBrief[] } token.value = data.token refreshToken.value = data.refreshToken expiresAt.value = data.expiresAt user.value = data.user permissions.value = data.permissions ?? [] + roles.value = data.roles ?? [] + // 恢复 permission.ts 读取的镜像副本:hasPermission() 走 localStorage, + // 只恢复 Pinia 而不同步这份镜像,会让刷新后的按钮级权限全部失效 + setAdminSession(data.user?.role ?? '', permissions.value) } catch { // corrupted data — ignore } @@ -79,6 +94,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = loginRes.expires_at user.value = loginRes.user permissions.value = loginRes.permissions ?? [] + roles.value = loginRes.roles ?? [] setTokens({ accessToken: loginRes.access_token, @@ -120,6 +136,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = null user.value = null permissions.value = [] + roles.value = [] pendingAgreements.value = [] clearTokens() @@ -152,6 +169,36 @@ export const useAuthStore = defineStore('admin-auth', () => { hydrate() } + /** + * 从服务端拉取当前用户的最新权限。 + * + * 权限此前只在登录响应里下发一次,之后由 localStorage 恢复:管理员改了某人的权限, + * 对方不重新登录就一直按旧权限渲染菜单。在应用启动与 token 刷新后调用本方法即可跟上。 + * + * 失败时保持现有权限不变 —— 后端鉴权以服务端为准,前端权限只影响展示, + * 一次网络抖动不该把用户的菜单清空。 + */ + async function fetchMe(): Promise { + if (!token.value) return + try { + const { data } = await request.get('/admin/auth/me') + const me = data?.data + if (!me) return + user.value = { + id: me.id, + username: me.username, + display_name: me.display_name, + role: me.role, + } + permissions.value = me.permissions ?? [] + roles.value = me.roles ?? [] + setAdminSession(me.role, permissions.value) + persist() + } catch { + // 保持现有权限 + } + } + // Sync Pinia store when Axios interceptor refreshes tokens onTokenRefreshed((tokens) => { token.value = tokens.accessToken @@ -176,6 +223,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt, user, permissions, + roles, pendingAgreements, rememberMe, isLoggedIn, @@ -186,6 +234,7 @@ export const useAuthStore = defineStore('admin-auth', () => { logoutLocal, refreshTokens, loadFromStorage, + fetchMe, clearPendingAgreements, } }) diff --git a/web/admin/src/styles/main.css b/web/admin/src/styles/main.css index 44fd04f..64839ab 100644 --- a/web/admin/src/styles/main.css +++ b/web/admin/src/styles/main.css @@ -242,6 +242,17 @@ body { color: #64748b; } +/* 表格横向滚动条细化:Arco 默认轨道 15px、滑块 9px,列多时悬停表头会出现粗滚动条遮住表头文字。 + 压成 3px 细线贴在底部,仅作「可横向滚动」的提示;拖拽/点击滚动同步逻辑不变,表头表体通用 */ +.arco-table .arco-scrollbar-track-direction-horizontal { + height: 3px; +} + +.arco-table .arco-scrollbar-thumb-direction-horizontal .arco-scrollbar-thumb-bar { + height: 3px; + margin: 0; +} + .arco-table-td { border-bottom: 1px solid var(--ta-border-light); } @@ -259,6 +270,40 @@ body { vertical-align: middle; } +/* page-table 布局下表体被拉伸为滚动容器,固定 240px 的空态行会顶在表体上方。 + 空数据时把表体区域(含固定列触发的表头/表体分离结构:Scrollbar 根 → arco-table-body → + table)逐层拉满表头以下的剩余高度,再让空态行撑满表格,「暂无数据」即在整个表体 + 可视区内垂直居中,表头行高度不变 */ +.page-table .arco-table-content:has(.arco-table-tr-empty) { + display: flex; + flex-direction: column; +} + +/* 表体区域的 Scrollbar 根(表头是第一个子节点,取 last-child 只拉伸表体) */ +.page-table .arco-table-content:has(.arco-table-tr-empty) > .arco-scrollbar:last-child { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* 表体滚动容器(Arco 把 body 类挂在 scrollbar-container 上) */ +.page-table .arco-table-content:has(.arco-table-tr-empty) .arco-table-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.page-table .arco-table-content:has(.arco-table-tr-empty) .arco-table-body .arco-table-element { + flex: 1; +} + +/* 空态行撑满拉高后的表格(此表只剩该行,无表头行分高) */ +.page-table .arco-table-content:has(.arco-table-tr-empty) .arco-table-tr-empty { + height: 100%; +} + /* Card */ .arco-card { border-radius: var(--ta-radius); @@ -464,16 +509,16 @@ body { z-index: 12; } -/* 右下角分页栏 */ +/* 右下角分页栏:表格底线(末行 td 的 border-bottom)即分隔线, + footer 不再画 border-top / 留 margin-top,避免表格底部出现两条临近分割线, + 仅用 padding-top 与表格保持间距 */ .page-table .table-footer { flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; gap: 12px; - margin-top: 12px; padding-top: 12px; - border-top: 1px solid var(--ta-border-light); } /* 统计总数从表格左上角移到底部后:footer 内存在统计栏时两端对齐(统计居左、分页居右), diff --git a/web/admin/src/utils/permission.ts b/web/admin/src/utils/permission.ts index dbf1c70..dfa7049 100644 --- a/web/admin/src/utils/permission.ts +++ b/web/admin/src/utils/permission.ts @@ -28,6 +28,18 @@ export function hasPermission(permission: string): boolean { return permissions.includes(permission) } +/** + * 是否超级管理员。 + * + * 用于「不参与权限点授权、硬性限定超管」的功能(如角色权限管理): + * 这类功能是权限体系的元操作,不能通过授予权限点下放,否则被下放者 + * 能给自己挂上更高权限的角色。后端有对应的 superAdminOnlyRules 拦截, + * 前端这份判断只用于隐藏入口,不构成安全边界。 + */ +export function isSuperAdmin(): boolean { + return hasRole(ADMIN_ROLES.SUPER_ADMIN) +} + export function hasRole(role: string): boolean { const currentRole = getStoredString(ADMIN_ROLE_KEY) return currentRole === role diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6250eeb..6ebfa50 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -1,5 +1,5 @@ + + + + diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index dfdd753..df6a75f 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -198,7 +198,7 @@ async function handle2FAVerify() { @@ -307,7 +307,7 @@ async function handle2FAVerify() { diff --git a/web/admin/src/views/WorkbenchPage.vue b/web/admin/src/views/WorkbenchPage.vue index 1430a2d..1b230e9 100644 --- a/web/admin/src/views/WorkbenchPage.vue +++ b/web/admin/src/views/WorkbenchPage.vue @@ -27,6 +27,7 @@ import { } from '@arco-design/web-vue/es/icon' import request from '@/utils/request' import { formatBilling } from '@/composables/useCurrency' +import { createPoller } from '@/composables/usePolling' const router = useRouter() @@ -68,7 +69,8 @@ const onlyUrgent = ref(false) const activeDomain = ref('all') const lastRefresh = ref(null) -let pollTimer: ReturnType | null = null +// 静默轮询:页面隐藏时暂停,恢复可见时按剩余时间续排 +const summaryPoller = createPoller(() => fetchSummary(true), 60000) // ===== 派生 ===== const items = computed(() => summary.value?.items || []) @@ -184,11 +186,11 @@ function availClass(m: any) { onMounted(() => { fetchSummary() // 后端整份结果有 30s Redis 缓存,前端 60s 轮询不会额外压库 - pollTimer = setInterval(() => fetchSummary(true), 60000) + summaryPoller.start() }) onBeforeUnmount(() => { - if (pollTimer) clearInterval(pollTimer) + summaryPoller.stop() }) diff --git a/web/admin/src/views/ai/ChannelDetailPage.vue b/web/admin/src/views/ai/ChannelDetailPage.vue index 5c5b7d3..85e163a 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, @@ -337,7 +339,7 @@ function renderHealthBadge(record: any) { // 新增:重置模型级健康 async function handleResetModelHealth(record: any) { try { - await request.post(`/admin/channels/${channelId}/reset_health`, { + await request.post(`/admin/channels/${channelId}/reset-health`, { model_name: record.model_name, }) Message.success(`模型 ${record.model_name} 健康已重置`) @@ -610,7 +612,7 @@ async function openDebugDetail(row: any) { debugDetail.value = null try { const res: any = await request.get(`/admin/channels/${channelId}/debug-logs/${row.id}`) - debugDetail.value = res.data?.data?.data || null + debugDetail.value = res.data?.data || null } catch { debugDetail.value = null } finally { @@ -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..b6b1f70 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', } @@ -694,9 +692,7 @@ const { exporting, exportFile } = useExport({ align-items: center; justify-content: space-between; gap: 12px; - margin-top: 16px; padding-top: 16px; - border-top: 1px solid var(--ta-border-light); } /* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */ .table-footer :deep(.table-stats) { diff --git a/web/admin/src/views/ai/ModelGroupsPage.vue b/web/admin/src/views/ai/ModelGroupsPage.vue index f433f12..017d891 100644 --- a/web/admin/src/views/ai/ModelGroupsPage.vue +++ b/web/admin/src/views/ai/ModelGroupsPage.vue @@ -326,12 +326,13 @@ onMounted(() => { :on-before-ok="handleSaveModels" :ok-loading="modelsLoading" > - + @@ -347,7 +348,6 @@ onMounted(() => { align-items: center; justify-content: space-between; gap: 12px; - margin-top: 16px; } /* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */ .table-footer :deep(.table-stats) { @@ -356,4 +356,12 @@ onMounted(() => { .mb-4 { margin-bottom: 16px; } +/* 模型穿梭框:弹窗内水平居中,两侧列表加高 */ +.models-transfer-wrap { + display: flex; + justify-content: center; +} +.models-transfer :deep(.arco-transfer-view) { + height: 420px; +} diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 5b722d1..5665b39 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -11,6 +11,7 @@ import request from '@/utils/request' import { useExport } from '@/composables/useExport' import ResponsiveTable from '@/components/ResponsiveTable.vue' import { formatBilling } from '@/composables/useCurrency' +import { vendorOptions, vendorLabelMap, vendorTagColor, filterVendorOption } from '@/constants/vendor' const loading = ref(false) const data = ref([]) @@ -23,6 +24,7 @@ const pagination = reactive({ }) const filterCategory = ref(null) +const filterVendor = ref(null) const filterStatus = ref(null) const filterSearch = ref('') const filterPricingStatus = ref(null) @@ -48,6 +50,8 @@ const categoryOptions = [ { label: '重排', value: 'rerank' }, ] +const vendorFilterOptions = [{ label: '全部厂商', value: '' }, ...vendorOptions] + const statusOptions = [ { label: '全部状态', value: '' }, { label: '启用', value: 'active' }, @@ -66,6 +70,7 @@ const form = reactive({ model_id: '', model_name: '', category: 'chat', + vendor: '', max_context_tokens: null as number | null, max_output_tokens: null as number | null, capabilities: {} as Record, @@ -157,6 +162,8 @@ const pricingModeTagColor: Record = { token: 'arcoblue', per_request: 'orangered', tiered: 'purple', + per_second: 'green', + special: 'orange', } // ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)===== @@ -172,18 +179,30 @@ function formatTokens(n: number | null | undefined): string { return String(n) } -// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显) +// 按秒模式基准单价:优先 "*" 兜底价,否则取矩阵最小正价(与定价弹窗 perSecondBasePrice 口径一致) +function perSecondBasePrice(row: any): number { + const prices = Object.values(row.per_second_prices || {}).map((p) => Number(p) || 0).filter((p) => p > 0) + if (prices.length === 0) return 0 + const wildcard = Number(row.per_second_prices?.['*']) || 0 + return wildcard > 0 ? wildcard : Math.min(...prices) +} + +// 定价主文本:本位币 输入/输出、单价/次、单价/秒、未定价(bil 层定价直显); +// special(特殊计费)按输出生成组件的每秒基准价展示 function pricingMain(row: any): string { if (!row.pricing_mode) return '未定价' if (row.pricing_mode === 'per_request') { return `${formatBilling(row.per_request_price ?? 0, 4)}/次` } + if (row.pricing_mode === 'per_second' || row.pricing_mode === 'special') { + return `${formatBilling(perSecondBasePrice(row), 4)}/秒` + } return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}` } -// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串) +// 定价模式副文本:按量 / 按次 / 阶梯 / 按秒 / 特殊(未定价返回空串) function pricingMode(row: any): string { - const m: Record = { token: '按量', per_request: '按次', tiered: '阶梯' } + const m: Record = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒', special: '特殊计费' } return row.pricing_mode ? (m[row.pricing_mode] || row.pricing_mode) : '' } @@ -213,6 +232,15 @@ const columns: TableColumnData[] = [ return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) }, }, + { + title: '厂商', + dataIndex: 'vendor', + width: 100, + render({ record }) { + if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—') + return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor) + }, + }, { title: '上下文', dataIndex: 'max_context_tokens', width: 100 }, { title: '输出上限', dataIndex: 'max_output_tokens', width: 100 }, { @@ -223,13 +251,16 @@ const columns: TableColumnData[] = [ if (!record.pricing_mode) { return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价') } - const modeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯' } + const modeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒', special: '特殊计费' } const tags = [ h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode), ] if (record.pricing_mode === 'per_request') { tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, `${formatBilling(record.per_request_price ?? 0, 4)}/次`)) + } else if (record.pricing_mode === 'per_second' || record.pricing_mode === 'special') { + tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, + `${formatBilling(perSecondBasePrice(record), 4)}/秒`)) } else { tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, `${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`)) @@ -313,6 +344,7 @@ async function fetchData() { page_size: pagination.pageSize, } if (filterCategory.value) params.category = filterCategory.value + if (filterVendor.value) params.vendor = filterVendor.value if (filterStatus.value) params.status = filterStatus.value if (filterSearch.value) params.search = filterSearch.value if (filterPricingStatus.value) params.pricing_status = filterPricingStatus.value @@ -340,6 +372,7 @@ function openCreate() { form.model_id = '' form.model_name = '' form.category = 'chat' + form.vendor = '' form.max_context_tokens = null form.max_output_tokens = null form.capabilities = {} @@ -358,6 +391,7 @@ function openEdit(row: any) { form.model_id = row.model_id form.model_name = row.model_name || '' form.category = row.category + form.vendor = row.vendor || '' form.max_context_tokens = row.max_context_tokens || null form.max_output_tokens = row.max_output_tokens || null form.capabilities = row.capabilities || {} @@ -428,6 +462,7 @@ const { exporting, exportFile } = useExport({ url: '/admin/models/export', getFilters: () => ({ category: filterCategory.value, + vendor: filterVendor.value, status: filterStatus.value, search: filterSearch.value, }), @@ -511,6 +546,15 @@ const importColumns: TableColumnData[] = [ return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) }, }, + { + title: '厂商', + dataIndex: 'vendor', + width: 90, + render({ record }) { + if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—') + return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor) + }, + }, { title: '状态', dataIndex: 'status', @@ -676,6 +720,16 @@ function resetImport() { style="width: 140px" @change="handleFilter" /> + - - + + + + + {{ opt.label }} + + - - - -
- + +
+ + + + + + +
+ +
+ - + + + + 拉取官方数据
-
- + +
+ - + + + + +
+ +
+ + + + + +
@@ -834,18 +919,6 @@ function resetImport() {
- - - - - - - - - - - - @@ -937,9 +1010,7 @@ function resetImport() { align-items: center; justify-content: space-between; gap: 12px; - margin-top: 16px; padding-top: 16px; - border-top: 1px solid var(--ta-border-light); } /* 统计栏移入底部后,去掉全局样式的下边距,与分页栏垂直居中 */ .table-footer :deep(.table-stats) { @@ -947,10 +1018,25 @@ function resetImport() { } .cap-grid { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px 0; width: 100%; } +/* 分类分段按钮组:等宽铺满表单整行 */ +.cat-seg { + display: flex; + width: 100%; +} +.cat-seg :deep(.arco-radio-button) { + flex: 1; +} +.cat-seg :deep(.arco-radio-button-content) { + text-align: center; +} +/* 弹窗 teleport 到 body,scoped 属性选择器不生效,用 :global 兜底移动端宽度 */ +:global(.model-edit-modal) { + max-width: calc(100vw - 24px); +} .import-upload-area { border: 1px dashed var(--color-border-2); border-radius: 8px; diff --git a/web/admin/src/views/ai/TaskLogsPage.vue b/web/admin/src/views/ai/TaskLogsPage.vue index 2ddcdfd..da8058e 100644 --- a/web/admin/src/views/ai/TaskLogsPage.vue +++ b/web/admin/src/views/ai/TaskLogsPage.vue @@ -1,5 +1,5 @@ + + + + diff --git a/web/admin/src/views/finance/TransactionsPage.vue b/web/admin/src/views/finance/TransactionsPage.vue index afb9ee3..cff5c04 100644 --- a/web/admin/src/views/finance/TransactionsPage.vue +++ b/web/admin/src/views/finance/TransactionsPage.vue @@ -138,8 +138,6 @@ onMounted(fetchData) .table-footer { display: flex; justify-content: flex-end; - margin-top: 16px; padding-top: 16px; - border-top: 1px solid var(--ta-border-light); } diff --git a/web/admin/src/views/finance/payment-channels/EpayChannelCard.vue b/web/admin/src/views/finance/payment-channels/EpayChannelCard.vue new file mode 100644 index 0000000..884b91f --- /dev/null +++ b/web/admin/src/views/finance/payment-channels/EpayChannelCard.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/web/admin/src/views/monitor/ChannelErrorsPage.vue b/web/admin/src/views/monitor/ChannelErrorsPage.vue index eb1ab1d..293fba0 100644 --- a/web/admin/src/views/monitor/ChannelErrorsPage.vue +++ b/web/admin/src/views/monitor/ChannelErrorsPage.vue @@ -1,11 +1,12 @@ + + diff --git a/web/admin/src/views/security/RequestAuditLogsPage.vue b/web/admin/src/views/security/RequestAuditLogsPage.vue index 62da0e8..e5bcb81 100644 --- a/web/admin/src/views/security/RequestAuditLogsPage.vue +++ b/web/admin/src/views/security/RequestAuditLogsPage.vue @@ -5,7 +5,6 @@ import { Tag, Button, } from '@arco-design/web-vue' import type { TableColumnData } from '@arco-design/web-vue' -import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import ForwardingTracePanel from '@/components/ForwardingTracePanel.vue' import request from '@/utils/request' @@ -31,6 +30,7 @@ const filter = reactive({ tenant_id: null as number | null, user_id: null as number | null, api_key_id: '', + model: '', date_range: defaultTodayRange(), }) @@ -140,7 +140,7 @@ const columns: TableColumnData[] = [ { title: 'API Key', dataIndex: 'api_key_name', width: 120, ellipsis: true, render({ record }) { return record.api_key_name || record.api_key_id || '-' }}, - { title: 'Request ID', dataIndex: 'request_id', width: 150, ellipsis: true }, + // Request ID 不在表格展示(体积长、噪声大),需要时可通过「详情」抽屉或上方筛选框查看 { title: '方法', dataIndex: 'method', width: 70, render({ record }) { @@ -149,6 +149,9 @@ const columns: TableColumnData[] = [ }, }, { title: '路径', dataIndex: 'path', width: 160, ellipsis: true }, + { title: '模型', dataIndex: 'model_name', width: 150, ellipsis: true, render({ record }) { + return record.model_name || '-' + }}, { title: '状态码', dataIndex: 'status_code', width: 80, render({ record }) { @@ -203,6 +206,7 @@ async function fetchData() { if (filter.tenant_id) params.tenant_id = filter.tenant_id if (filter.user_id) params.user_id = filter.user_id if (filter.api_key_id) params.api_key_id = parseInt(filter.api_key_id) + if (filter.model) params.model = filter.model if (filter.date_range.length === 2) { params.start_date = filter.date_range[0] // 截止时间为默认「现在」时不传 end_date(后端按「到现在」实时处理),仅手动选择后才显式下发 @@ -253,6 +257,7 @@ function handleReset() { filter.tenant_id = null filter.user_id = null filter.api_key_id = '' + filter.model = '' filter.date_range = defaultTodayRange() pagination.current = 1 fetchData() @@ -275,10 +280,8 @@ onMounted(() => { diff --git a/web/admin/src/views/system/SettingsPage.vue b/web/admin/src/views/system/SettingsPage.vue index 7081713..1c93be4 100644 --- a/web/admin/src/views/system/SettingsPage.vue +++ b/web/admin/src/views/system/SettingsPage.vue @@ -1,5 +1,6 @@ @@ -118,4 +257,136 @@ const values = useFormValues() line-height: 1.6; margin-top: -8px; } +.section-title-with-help { + display: flex; + align-items: center; + gap: 6px; +} +.help-icon { + color: var(--color-text-3); + cursor: pointer; + transition: color 0.2s; + flex: 0 0 auto; +} +.help-icon:hover { + color: rgb(var(--arcoblue-6)); +} + +/* 路由策略说明弹窗(样式惯例对齐 ChannelsPage 调度说明弹窗) */ +.policy-guide { + color: var(--color-text-1); + font-size: 13px; + line-height: 1.7; + max-height: 65vh; + overflow-y: auto; + padding-right: 6px; +} +.policy-guide .guide-intro { + margin: 0 0 14px; + color: var(--color-text-2); +} +.policy-guide .guide-section { + margin-bottom: 16px; +} +.policy-guide .guide-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-index { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + flex: 0 0 22px; + border-radius: 50%; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); + font-size: 12px; +} +.policy-guide p { + margin: 0; +} +.policy-guide p + p { + margin-top: 6px; +} +.policy-guide code { + padding: 1px 4px; + border-radius: 3px; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); +} +.guide-warning { + display: flex; + gap: 8px; + padding: 10px 12px; + margin-bottom: 14px; + border-radius: 6px; + background: var(--color-warning-light-1); +} +.guide-warning .warning-icon { + color: rgb(var(--warning-6)); + flex: 0 0 auto; + margin-top: 3px; +} +.guide-rules { + margin: 0; + padding-left: 20px; +} +.guide-rules li { + margin-bottom: 4px; +} +.policy-table-wrap { + overflow-x: auto; +} +.policy-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} +.policy-table th, +.policy-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--color-fill-2); + text-align: left; + vertical-align: top; +} +.policy-table th { + font-weight: 600; + white-space: nowrap; + background: var(--color-fill-1); +} +.policy-table td:first-child { + white-space: nowrap; + font-weight: 500; + color: var(--color-text-1); +} +.policy-guide .guide-example { + padding: 14px 16px; + border-radius: 6px; + background: var(--color-fill-2); +} +.policy-guide .guide-example-title { + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-example p { + margin: 8px 0 0; +} +.policy-guide pre { + margin: 8px 0 0; + padding: 12px 14px; + border-radius: 6px; + background: var(--color-fill-1); + overflow-x: auto; + font-size: 12.5px; +} +.policy-guide pre code { + padding: 0; + background: none; + color: inherit; +} diff --git a/web/admin/src/views/system/settings/DataGovernanceTab.vue b/web/admin/src/views/system/settings/DataGovernanceTab.vue index ed7c52b..bfe2598 100644 --- a/web/admin/src/views/system/settings/DataGovernanceTab.vue +++ b/web/admin/src/views/system/settings/DataGovernanceTab.vue @@ -35,9 +35,6 @@ function set(key: string, v: number | undefined): void { - - -
diff --git a/web/admin/src/views/system/settings/GeneralTab.vue b/web/admin/src/views/system/settings/GeneralTab.vue index 7e5d41b..254afa2 100644 --- a/web/admin/src/views/system/settings/GeneralTab.vue +++ b/web/admin/src/views/system/settings/GeneralTab.vue @@ -1,7 +1,19 @@ @@ -312,6 +355,17 @@ const { exporting, exportFile } = useExport({ style="width: 120px" @change="handleFilter" /> + + + {{ opt.name }} + + + 搜索 @@ -330,13 +392,13 @@ const { exporting, exportFile } = useExport({ :columns="columns" :data="data" :loading="loading" - :scroll="{ x: 1300 }" + :scroll="{ x: 1530 }" :stripe="true" row-key="id" card-title-key="name" card-subtitle-key="code" card-badge-key="status" - :card-fields="['owner_name', 'member_count', 'level', 'wallet_balance']" + :card-fields="['owner_name', 'member_count', 'level', 'wallet_balance', 'total_consumed']" /> @@ -430,8 +504,6 @@ const { exporting, exportFile } = useExport({ .table-footer { display: flex; justify-content: flex-end; - margin-top: 16px; padding-top: 16px; - border-top: 1px solid var(--ta-border-light); } diff --git a/web/admin/src/views/tenant/UsageLogsPage.vue b/web/admin/src/views/tenant/UsageLogsPage.vue index 63b3d9a..27fde05 100644 --- a/web/admin/src/views/tenant/UsageLogsPage.vue +++ b/web/admin/src/views/tenant/UsageLogsPage.vue @@ -6,7 +6,6 @@ import { } from '@arco-design/web-vue' import { IconSave, IconFile, IconBarChart, IconCamera, IconExclamationCircle } from '@arco-design/web-vue/es/icon' import type { TableColumnData } from '@arco-design/web-vue' -import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import request from '@/utils/request' import ResponsiveTable from '@/components/ResponsiveTable.vue' @@ -45,10 +44,12 @@ const filterApiKeyId = ref(undefined) const filterRequestType = ref(undefined) const filterChannelId = ref(undefined) const filterStatus = ref(undefined) +const filterUpstreamRequestId = ref(undefined) const filterDateRange = ref(defaultTodayRange()) const tenantOptions = ref<{ label: string; value: number }[]>([]) const modelOptions = ref<{ label: string; value: string }[]>([]) +const channelOptions = ref<{ label: string; value: number }[]>([]) const statusOptions = [ { label: '成功', value: 'success' }, @@ -65,7 +66,6 @@ const requestTypeOptions = [ ] let tenantSearchTimer: ReturnType | null = null -let modelSearchTimer: ReturnType | null = null async function fetchTenantOptions(keyword = '') { try { @@ -87,11 +87,12 @@ function handleTenantSearch(value: string) { tenantSearchTimer = setTimeout(() => fetchTenantOptions(value), 300) } -async function fetchModelOptions(search = '') { +// 模型下拉数据:专用不分页接口 /admin/models/options,一次拉回全部 active 模型后本地过滤。 +// 不用 /admin/models 列表接口——那是模型管理页的重接口(分页 + 定价 JSONB 展开 + +// 渠道能力联表),仅取 model_id / model_name 做下拉纯属浪费。 +async function fetchModelOptions() { try { - const res: any = await request.get('/admin/models', { - params: { page: 1, page_size: 50, status: 'active', search } - }) + const res: any = await request.get('/admin/models/options') const list = res.data?.data?.list || [] modelOptions.value = list.map((m: any) => ({ label: m.model_name || m.model_id, @@ -102,9 +103,29 @@ async function fetchModelOptions(search = '') { } } -function handleModelSearch(value: string) { - if (modelSearchTimer) clearTimeout(modelSearchTimer) - modelSearchTimer = setTimeout(() => fetchModelOptions(value), 300) +// 模型下拉本地过滤:候选是全量模型,label 用的是 model_name、value 是 model_id, +// 只在 label 上匹配会让「显示名不含 model_id」的模型(如中文显示名)搜不到。 +// 这里对齐原先服务端 model_id LIKE ? OR model_name LIKE ? 的口径,name 与 id 任一命中即可。 +function filterModelOption(input: string, option: any): boolean { + const keyword = input.toLowerCase() + return String(option?.label ?? '').toLowerCase().includes(keyword) + || String(option?.value ?? '').toLowerCase().includes(keyword) +} + +// 渠道下拉数据:专用不分页接口 /admin/channels/options,返回全部渠道(含已停用, +// 历史日志要能按停用渠道筛选),一次拉全量后本地过滤。 +// 不用 /admin/channels——那是渠道管理列表(分页 + 健康度联表 + 运行态 + 上限 100 条)。 +async function fetchChannelOptions() { + try { + const res: any = await request.get('/admin/channels/options') + const list = res.data?.data?.list || [] + channelOptions.value = list.map((c: any) => ({ + label: c.name ? `${c.name} (#${c.id})` : `#${c.id}`, + value: c.id, + })) + } catch { + channelOptions.value = [] + } } const statusTagColor: Record = { @@ -143,12 +164,16 @@ const billingModeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯', + per_second: '按秒', + special: '特殊计费', } const billingModeColor: Record = { token: 'gray', per_request: 'blue', tiered: 'arcoblue', + per_second: 'green', + special: 'orange', } const billingSourceLabel: Record = { @@ -157,6 +182,7 @@ const billingSourceLabel: Record = { tenant: '租户定价', custom: '自定义', plan: '套餐价', + task: '异步任务', } const detailVisible = ref(false) @@ -246,6 +272,31 @@ const tokenCostLabels: Record = { cache_creation_1h: '缓存创建(1小时)', } +// 快照计费模式是否为 token 语义(token/tiered 展示每 1M 单价行; +// per_request/per_second/special 的输入输出价恒 0,展示会误导) +function snapshotIsTokenPricing(sp: any): boolean { + const mode = sp?.pricing?.billing_mode + return mode === 'token' || mode === 'tiered' || !mode +} + +// 快照按秒矩阵 → 排序后的规格列表(数字序,"*" 兜底档排最后;仅 per_second/special 携带) +function snapshotPerSecondEntries(sp: any): Array<[string, number]> { + const prices = sp?.pricing?.per_second_prices + if (!prices) return [] + return Object.entries(prices) + .filter(([, v]) => Number(v) > 0) + .sort(([a], [b]) => { + if (a === '*') return 1 + if (b === '*') return -1 + return a.localeCompare(b, undefined, { numeric: true }) + }) +} + +// 快照是否有 token 费用明细(视频等按秒/特殊任务 token 恒 0,隐藏空块) +function snapshotHasTokenCosts(sp: any): boolean { + return Object.values(sp?.token_costs || {}).some((tc: any) => (tc?.tokens || 0) > 0) +} + // 消费明细小票行:divider=true 渲染分隔线,strong=true 渲染小计行,total=true 渲染合计行 interface ReceiptRow { label?: string @@ -317,11 +368,24 @@ function viewAuditLog(requestId: string, taskId?: string) { router.push({ name: 'AdminRequestAuditLogs', query }) } -function openDetail(record: any) { - detailLog.value = record +// 详情弹窗改为按 id 拉取:列表接口只返回表格展示字段, +// billing_snapshot / billing_summary / user_agent / error_message 等大字段经详情接口按需加载 +const detailLoading = ref(false) + +async function openDetail(record: any) { // 每次打开重置为折叠,避免上一条记录的快照展开状态带入 snapshotCollapsed.value = true detailVisible.value = true + detailLoading.value = true + detailLog.value = null + try { + const res: any = await request.get(`/admin/usage-logs/${record.id}`) + detailLog.value = res.data?.data || null + } catch { + detailLog.value = null + } finally { + detailLoading.value = false + } } function tooltipRow(label: string, value: string, valueClass = 'dark-tooltip-value') { @@ -373,7 +437,7 @@ const columns: TableColumnData[] = [ h(Tag, { color: requestTypeColor[record.request_type], size: 'small' }, () => requestTypeLabel[record.request_type] || '-'), ] if (record.billing_mode) { - tags.push(h(Tag, { color: billingModeColor[record.billing_mode], size: 'small' }, () => billingModeLabel[record.billing_mode])) + tags.push(h(Tag, { color: billingModeColor[record.billing_mode], size: 'small' }, () => billingModeLabel[record.billing_mode] || record.billing_mode)) } return h(Space, { size: 4 }, () => tags) }, @@ -512,6 +576,7 @@ async function fetchData() { if (filterRequestType.value) params.request_type = filterRequestType.value if (filterChannelId.value) params.channel_id = filterChannelId.value if (filterStatus.value) params.status = filterStatus.value + if (filterUpstreamRequestId.value) params.upstream_request_id = filterUpstreamRequestId.value if (filterDateRange.value && filterDateRange.value.length === 2) { params.start_date = filterDateRange.value[0] // 截止时间为默认「现在」时不传 end_date(后端按「到现在」实时处理),仅手动选择后才显式下发 @@ -573,8 +638,9 @@ async function fetchSummary() { function handleFilter() { pagination.current = 1 - fetchData() - fetchSummary() // 筛选条件变化时重新加载统计数据 + // 表格数据加载完成后再刷新统计:summary 是对明细表的全量聚合, + // 与列表查询并发会争抽数据库资源、拖慢表格首屏 + fetchData().then(() => fetchSummary()) } function handleReset() { @@ -586,10 +652,11 @@ function handleReset() { filterRequestType.value = undefined filterChannelId.value = undefined filterStatus.value = undefined + filterUpstreamRequestId.value = undefined filterDateRange.value = defaultTodayRange() pagination.current = 1 - fetchData() - fetchSummary() // 重置后重新加载统计数据 + // 与 handleFilter 同口径:先加载表格数据,完成后再刷新统计 + fetchData().then(() => fetchSummary()) } // 刷新:清空所有筛选条件,仅按当天起始时间查询最新记录(截止留空 = 到现在) @@ -600,8 +667,10 @@ function handleRefresh() { onMounted(() => { fetchTenantOptions() fetchModelOptions() - fetchData() - fetchSummary() // 初始加载时获取统计数据 + fetchChannelOptions() + // 表格数据加载完成后再拉统计汇总,避免 summary 的全量聚合 + // 与列表首查并发争抽数据库资源(fetchData 内部已捕获异常,必 resolve) + fetchData().then(() => fetchSummary()) }) const { exporting, exportFile } = useExport({ @@ -615,6 +684,7 @@ const { exporting, exportFile } = useExport({ request_type: filterRequestType.value, channel_id: filterChannelId.value, status: filterStatus.value, + upstream_request_id: filterUpstreamRequestId.value, start_date: filterDateRange.value?.[0], // 与列表查询一致:默认截止「现在」不传截止时间,按「到现在」实时导出 end_date: filterDateRange.value?.[1] && filterDateRange.value[1] !== defaultEnd ? filterDateRange.value[1] : undefined, @@ -624,22 +694,9 @@ const { exporting, exportFile } = useExport({