mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Compare commits
294
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d19265ddb2 | ||
|
|
92c9433d40 | ||
|
|
9c2cca9e62 | ||
|
|
4d9ac3e482 | ||
|
|
01842576b3 | ||
|
|
49b2122dc6 | ||
|
|
8d7d3185e9 | ||
|
|
bba85923b9 | ||
|
|
49dc6e2363 | ||
|
|
9d5e63a598 | ||
|
|
53c21fed25 | ||
|
|
7c5afa4c36 | ||
|
|
b669dc6daf | ||
|
|
c4bdc48475 | ||
|
|
26f70c6a87 | ||
|
|
cfbbfd6b3b | ||
|
|
43a3c197cb | ||
|
|
2023c290e1 | ||
|
|
ab80abc6d1 | ||
|
|
492c630599 | ||
|
|
ba2a717332 | ||
|
|
8b74741efe | ||
|
|
fc82e83102 | ||
|
|
9591136d45 | ||
|
|
26b31a83e4 | ||
|
|
7b03b1f75a | ||
|
|
43768680d3 | ||
|
|
0718581265 | ||
|
|
e468262294 | ||
|
|
5dbb8f7085 | ||
|
|
6d509c632d | ||
|
|
6a6d4728a0 | ||
|
|
832677a5e6 | ||
|
|
c2885c430f | ||
|
|
59fb22791e | ||
|
|
20b0be5b05 | ||
|
|
d70265306b | ||
|
|
f2b1556bd4 | ||
|
|
0ae1b371fa | ||
|
|
da9799542d | ||
|
|
6977bb63bb | ||
|
|
b63dafa426 | ||
|
|
daebb7a80c | ||
|
|
4426593e9d | ||
|
|
f0ee427f0a | ||
|
|
05d7946af8 | ||
|
|
c9e4b267b6 | ||
|
|
6f9b02f12c | ||
|
|
1c68f9996d | ||
|
|
2588675ce7 | ||
|
|
81e6ec5346 | ||
|
|
64331a9792 | ||
|
|
3abd646582 | ||
|
|
d5a802f84a | ||
|
|
3bfd40f5af | ||
|
|
0f189af8de | ||
|
|
ce6242fca1 | ||
|
|
66b7ce08e7 | ||
|
|
29b2d721f4 | ||
|
|
5753552b95 | ||
|
|
8e3d75b57b | ||
|
|
bd2f53b855 | ||
|
|
d01628975d | ||
|
|
2bfc49477c | ||
|
|
b686842716 | ||
|
|
9a51333101 | ||
|
|
3a6fd1c9db | ||
|
|
f2669c8cf6 | ||
|
|
85e3110953 | ||
|
|
64caa9af8b | ||
|
|
dab3b87ea2 | ||
|
|
d0392edf06 | ||
|
|
d85cebecc4 | ||
|
|
5f93bb3b3f | ||
|
|
c61f6ebaa8 | ||
|
|
387ee20ed1 | ||
|
|
7ca08d521f | ||
|
|
9264414612 | ||
|
|
874871a25a | ||
|
|
e2939cd8fa | ||
|
|
7ffa38a8ae | ||
|
|
c556aee8fd | ||
|
|
eefa7dd9dc | ||
|
|
3b89cc08dd | ||
|
|
105c955c88 | ||
|
|
8ac2936a3d | ||
|
|
d56f5e7879 | ||
|
|
3f8eea0807 | ||
|
|
c063a4016e | ||
|
|
ba280b3d5e | ||
|
|
5fc0e486c3 | ||
|
|
3e69643392 | ||
|
|
3a012ac0ce | ||
|
|
80dd7288a6 | ||
|
|
5a6e826f83 | ||
|
|
d068b866f3 | ||
|
|
a618338987 | ||
|
|
b0c2c4d623 | ||
|
|
9663b5ccf0 | ||
|
|
6cfa0f4ef8 | ||
|
|
3149d62851 | ||
|
|
0b51cbe15d | ||
|
|
3dc2a9c371 | ||
|
|
2c13fb595e | ||
|
|
068239305e | ||
|
|
9e46f2d77e | ||
|
|
2eb7ccb6d2 | ||
|
|
745ff1d3ca | ||
|
|
8e2c4fad32 | ||
|
|
0ac774e378 | ||
|
|
ba3af61251 | ||
|
|
48c647b5d2 | ||
|
|
b94974467b | ||
|
|
4747827e35 | ||
|
|
f3bcad3c33 | ||
|
|
2c6bd63de9 | ||
|
|
02aad478f3 | ||
|
|
486b226b69 | ||
|
|
d1202bea5f | ||
|
|
1865f28469 | ||
|
|
c2f0edc137 | ||
|
|
cdbf1d6f4d | ||
|
|
b126b94525 | ||
|
|
7fc5332b45 | ||
|
|
4bcad0657b | ||
|
|
7ff5cff1bb | ||
|
|
bc63bd2874 | ||
|
|
458865ed1b | ||
|
|
3a7fd4ae78 | ||
|
|
4b8c557705 | ||
|
|
bb0d4a53c9 | ||
|
|
3c274768c1 | ||
|
|
bbf1d37783 | ||
|
|
90afaf120e | ||
|
|
6cacccc767 | ||
|
|
2afe7d4577 | ||
|
|
129369204e | ||
|
|
f19cd4fdea | ||
|
|
e473fba99c | ||
|
|
cc4a1e3f61 | ||
|
|
9d49d275f3 | ||
|
|
8d50466809 | ||
|
|
f9bd97cc15 | ||
|
|
f087fdb205 | ||
|
|
ef16f8a3d1 | ||
|
|
6db4171cfb | ||
|
|
db29b3a773 | ||
|
|
7cd3dc85c3 | ||
|
|
df3c38e10f | ||
|
|
07d51a6aef | ||
|
|
18182f1127 | ||
|
|
1817a8428a | ||
|
|
38dd23f834 | ||
|
|
6f6a27f9c6 | ||
|
|
61e1afdd49 | ||
|
|
8811c125ec | ||
|
|
3f2f66f328 | ||
|
|
f022a04573 | ||
|
|
795a7e2783 | ||
|
|
7b1aa93148 | ||
|
|
d5f490e106 | ||
|
|
3f1a2ea0a7 | ||
|
|
0363b8cdba | ||
|
|
0f9d460dc6 | ||
|
|
6e315616ed | ||
|
|
daa7915749 | ||
|
|
63800c50d4 | ||
|
|
d97ccc952f | ||
|
|
33d9e72f90 | ||
|
|
df89228c2d | ||
|
|
480f668ba9 | ||
|
|
aa3b049000 | ||
|
|
0dccd7b6f5 | ||
|
|
d1aac6b989 | ||
|
|
54810f8c82 | ||
|
|
5ef517d0fc | ||
|
|
0173861a5a | ||
|
|
f7f1112cda | ||
|
|
c15d858eca | ||
|
|
412718edf5 | ||
|
|
9f84204c21 | ||
|
|
679a7186fb | ||
|
|
bbba01dae9 | ||
|
|
da4419e626 | ||
|
|
b07b1fb5d9 | ||
|
|
81bd85ec55 | ||
|
|
b472042513 | ||
|
|
4551fb0d86 | ||
|
|
8f636513f7 | ||
|
|
f385bba928 | ||
|
|
856d7ea905 | ||
|
|
abbe42ac44 | ||
|
|
a46249aac5 | ||
|
|
5a25507dcb | ||
|
|
852f0f824f | ||
|
|
5800abf1cb | ||
|
|
abdfbb704c | ||
|
|
1c996fe301 | ||
|
|
4b6ed15a8b | ||
|
|
b8dece9000 | ||
|
|
3040209f20 | ||
|
|
f5aca74623 | ||
|
|
432a6a461f | ||
|
|
c2d5bbd93e | ||
|
|
ae501cc220 | ||
|
|
99efaa477f | ||
|
|
d9d4333795 | ||
|
|
18380398d5 | ||
|
|
8b9788eeb4 | ||
|
|
66a9e2e6bb | ||
|
|
dd7ee21703 | ||
|
|
c0e5a4670e | ||
|
|
983cf297da | ||
|
|
458b92abd4 | ||
|
|
5106065716 | ||
|
|
46f5f5e8ba | ||
|
|
2529371bab | ||
|
|
43c4c882fd | ||
|
|
719013053a | ||
|
|
f3dc0835df | ||
|
|
5637f469fa | ||
|
|
34fe845f5a | ||
|
|
fa49647c6d | ||
|
|
9eb394e3e9 | ||
|
|
2d8a386811 | ||
|
|
bb97ab364a | ||
|
|
7225538358 | ||
|
|
0cecf3c367 | ||
|
|
1aa34d478d | ||
|
|
1b1039f4fd | ||
|
|
4407849142 | ||
|
|
1888de135d | ||
|
|
b6a157c19c | ||
|
|
775c1a3c1d | ||
|
|
15f469c86b | ||
|
|
7a767d5ce8 | ||
|
|
9a8ab4f88e | ||
|
|
f3d752693f | ||
|
|
71e97d1353 | ||
|
|
d6adebd22d | ||
|
|
53158587a6 | ||
|
|
ad05eda157 | ||
|
|
fdce5b8d01 | ||
|
|
73381fc9c7 | ||
|
|
27f4a1780f | ||
|
|
3613174cf6 | ||
|
|
00940743d2 | ||
|
|
2e3c2cf592 | ||
|
|
21d90fb5e5 | ||
|
|
75420462a6 | ||
|
|
42bc7f6cff | ||
|
|
96f4c115c9 | ||
|
|
327c322184 | ||
|
|
48b7109588 | ||
|
|
4f0c079e27 | ||
|
|
b0cfff9826 | ||
|
|
69d6d106fd | ||
|
|
b5efbe3f4c | ||
|
|
c641453123 | ||
|
|
e0019da463 | ||
|
|
00a922c429 | ||
|
|
e6d191a83f | ||
|
|
c91bb6124a | ||
|
|
9688571a83 | ||
|
|
2e2b8f6d59 | ||
|
|
8167d85b32 | ||
|
|
8a495b0a54 | ||
|
|
8bc375b592 | ||
|
|
cee76030be | ||
|
|
3a1a37b7b9 | ||
|
|
7d0bf9c87f | ||
|
|
514ad0aa8e | ||
|
|
dc2efc6b9a | ||
|
|
636faa695b | ||
|
|
e180a939ac | ||
|
|
c4224045dc | ||
|
|
5ace0699b2 | ||
|
|
1872d44b58 | ||
|
|
196b47757a | ||
|
|
27de27ac64 | ||
|
|
a4ea8059b7 | ||
|
|
a56720855c | ||
|
|
475c0839c6 | ||
|
|
b886fb8edb | ||
|
|
652a7d74f9 | ||
|
|
52c123e6f4 | ||
|
|
43f88c99aa | ||
|
|
b1ce3c6d59 | ||
|
|
d359196adc | ||
|
|
cf19f30d26 | ||
|
|
f48866958b | ||
|
|
58d605083e | ||
|
|
0da18e4a5e | ||
|
|
d1ba57977d |
@@ -0,0 +1,107 @@
|
||||
# Security Policy
|
||||
|
||||
[English](#english) | [中文](#中文)
|
||||
|
||||
## English
|
||||
|
||||
### Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.**
|
||||
|
||||
Report them privately through GitHub's private vulnerability reporting:
|
||||
|
||||
**[Report a vulnerability](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
|
||||
|
||||
(Repository → **Security** tab → **Report a vulnerability**.) Only the maintainers can see the report, and the advisory becomes the place to discuss, fix, and eventually publish the issue.
|
||||
|
||||
If you cannot use GitHub private vulnerability reporting, email **dev@sub2api.org** instead, with `[Security]` in the subject line.
|
||||
|
||||
A useful report includes:
|
||||
|
||||
- The affected component and version (release tag or commit)
|
||||
- Impact: what an attacker can do and which privileges they need
|
||||
- Minimal reproduction steps or a proof of concept
|
||||
- Any non-default configuration required to trigger the issue
|
||||
- A suggested fix, if you have one
|
||||
|
||||
### Supported Versions
|
||||
|
||||
Sub2API releases frequently, and fixes ship in a new release rather than being backported.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| Latest release | ✅ |
|
||||
| Older releases | ❌ |
|
||||
|
||||
Please confirm that the issue reproduces on the latest release or on `main` before reporting.
|
||||
|
||||
### Scope
|
||||
|
||||
In scope: the Sub2API backend, frontend, official Docker images, and deployment files in this repository.
|
||||
|
||||
Usually out of scope:
|
||||
|
||||
- Issues that require already-compromised admin credentials or server access
|
||||
- Risks arising from explicitly weakening documented security settings (for example, disabling `security.url_allowlist`)
|
||||
- Vulnerabilities in upstream AI providers or third-party dependencies with no Sub2API-specific impact (please report those upstream)
|
||||
- Missing best-practice headers or scanner output without a demonstrated impact
|
||||
|
||||
### Disclosure Process
|
||||
|
||||
1. We acknowledge the report and assess its validity and severity.
|
||||
2. We develop a fix privately in the advisory and may ask you to verify it.
|
||||
3. We publish a release containing the fix, then publish the GitHub Security Advisory (with a CVE when applicable), crediting you unless you prefer to stay anonymous.
|
||||
|
||||
Please keep details private until the advisory is published. This is a volunteer-maintained project, so response times are best effort.
|
||||
|
||||
## 中文
|
||||
|
||||
### 报告漏洞
|
||||
|
||||
**请不要通过公开的 Issue、Pull Request 或 Discussion 报告安全漏洞。**
|
||||
|
||||
请使用 GitHub 私密漏洞报告功能提交:
|
||||
|
||||
**[提交漏洞报告](https://github.com/Wei-Shaw/sub2api/security/advisories/new)**
|
||||
|
||||
(仓库 → **Security** 标签页 → **Report a vulnerability**。)报告仅维护者可见,后续的讨论、修复与公告发布都在该安全公告中进行。
|
||||
|
||||
如果无法使用 GitHub 私密漏洞报告,也可以发送邮件至 **dev@sub2api.org**,邮件标题请注明 `[Security]`。
|
||||
|
||||
报告中建议包含:
|
||||
|
||||
- 受影响的组件与版本(Release tag 或 commit)
|
||||
- 影响:攻击者能做什么、需要什么权限
|
||||
- 最小复现步骤或 PoC
|
||||
- 触发问题所需的非默认配置
|
||||
- 修复建议(如有)
|
||||
|
||||
### 支持的版本
|
||||
|
||||
Sub2API 发版频繁,安全修复随新版本发布,不向旧版本回移。
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
| ---- | -------- |
|
||||
| 最新 Release | ✅ |
|
||||
| 更早的版本 | ❌ |
|
||||
|
||||
报告前请先确认问题在最新 Release 或 `main` 上仍可复现。
|
||||
|
||||
### 范围
|
||||
|
||||
范围内:本仓库中的 Sub2API 后端、前端、官方 Docker 镜像与部署文件。
|
||||
|
||||
通常不在范围内:
|
||||
|
||||
- 需要事先已掌握管理员凭据或服务器权限才能利用的问题
|
||||
- 因主动关闭文档中的安全配置(如关闭 `security.url_allowlist`)而产生的风险
|
||||
- 上游 AI 服务商或第三方依赖自身的漏洞,且对 Sub2API 无特定影响(请向上游报告)
|
||||
- 仅缺少最佳实践响应头或扫描器输出,但未证明实际影响
|
||||
|
||||
### 披露流程
|
||||
|
||||
1. 我们确认收到报告,并评估其有效性与严重程度。
|
||||
2. 我们在安全公告中私下开发修复,可能请你协助验证。
|
||||
3. 我们先发布包含修复的版本,再公开 GitHub 安全公告(适用时申请 CVE),并致谢报告者(如你希望匿名则不署名)。
|
||||
|
||||
在公告发布前请勿公开漏洞细节。本项目由志愿者维护,响应时间尽力而为。
|
||||
@@ -3,16 +3,16 @@ exceptions:
|
||||
- package: xlsx
|
||||
advisory: "GHSA-4r6h-8v6p-xvw6"
|
||||
severity: high
|
||||
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2023-30533)"
|
||||
mitigation: "Load only on export; restrict export permissions and data scope"
|
||||
expires_on: "2026-10-06"
|
||||
reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2023-30533)"
|
||||
mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry"
|
||||
expires_on: "2027-01-06"
|
||||
owner: "security@your-domain"
|
||||
- package: xlsx
|
||||
advisory: "GHSA-5pgg-2g8v-p4x9"
|
||||
severity: high
|
||||
reason: "Admin export only; switched to dynamic import to reduce exposure (CVE-2024-22363)"
|
||||
mitigation: "Load only on export; restrict export permissions and data scope"
|
||||
expires_on: "2026-10-06"
|
||||
reason: "Write-only: admin UsageView lazily imports xlsx solely for XLSX.write export; the advisory requires parsing a crafted file and no XLSX.read path exists (CVE-2024-22363)"
|
||||
mitigation: "Never parse untrusted spreadsheets with xlsx; no patched version on npm, migrate to SheetJS CDN build or another writer before expiry"
|
||||
expires_on: "2027-01-06"
|
||||
owner: "security@your-domain"
|
||||
- package: lodash
|
||||
advisory: "GHSA-r5fr-rjxr-66jc"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.27.0'
|
||||
go version | grep -q 'go1.27.2'
|
||||
- name: Unit tests
|
||||
working-directory: backend
|
||||
run: make test-unit
|
||||
@@ -75,11 +75,11 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.27.0'
|
||||
go version | grep -q 'go1.27.2'
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.13
|
||||
version: v2.14.0
|
||||
args: --timeout=30m
|
||||
working-directory: backend
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
check-latest: false
|
||||
cache: false
|
||||
- name: Verify Go version
|
||||
run: go version | grep -q 'go1.27.0'
|
||||
run: go version | grep -q 'go1.27.2'
|
||||
- name: Restore target-specific Go cache
|
||||
id: go-cache
|
||||
uses: actions/cache/restore@v6
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Verify Go version
|
||||
run: |
|
||||
go version | grep -q 'go1.27.0'
|
||||
go version | grep -q 'go1.27.2'
|
||||
- name: Run govulncheck
|
||||
working-directory: backend
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
# =============================================================================
|
||||
|
||||
ARG NODE_IMAGE=node:24-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.27.0-alpine
|
||||
ARG GOLANG_IMAGE=golang:1.27.2-alpine
|
||||
ARG ALPINE_IMAGE=alpine:3.21
|
||||
ARG POSTGRES_IMAGE=postgres:18-alpine
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
@@ -5,9 +5,16 @@ FRONTEND_CRITICAL_VITEST := \
|
||||
src/api/__tests__/client.spec.ts \
|
||||
src/api/__tests__/tokenRefresh.spec.ts \
|
||||
src/api/__tests__/keys.bulkUpdate.spec.ts \
|
||||
src/composables/__tests__/useModelWhitelist.spec.ts \
|
||||
src/components/account/__tests__/OpenAIReferralCell.spec.ts \
|
||||
src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \
|
||||
src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \
|
||||
src/constants/__tests__/platforms.spec.ts \
|
||||
src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts \
|
||||
src/components/account/__tests__/CreateAccountModal.spec.ts \
|
||||
src/components/account/__tests__/EditAccountModal.spec.ts \
|
||||
src/components/account/__tests__/credentialsBuilder.spec.ts \
|
||||
src/components/account/__tests__/OpenCodeGoProtocolRulesEditor.spec.ts \
|
||||
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
|
||||
src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts \
|
||||
src/views/user/__tests__/KeysView.spec.ts \
|
||||
@@ -18,10 +25,12 @@ FRONTEND_CRITICAL_VITEST := \
|
||||
src/views/user/__tests__/PaymentResultView.spec.ts \
|
||||
src/views/user/__tests__/ChannelStatusView.mode.spec.ts \
|
||||
src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \
|
||||
src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts \
|
||||
src/views/admin/__tests__/SettingsView.spec.ts \
|
||||
src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \
|
||||
src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \
|
||||
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts
|
||||
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts \
|
||||
src/components/admin/channel/__tests__/PricingEntryCard.modelDefaultPrice.spec.ts
|
||||
|
||||
# 一键编译前后端
|
||||
build: build-backend build-frontend
|
||||
|
||||
@@ -54,8 +54,8 @@ Please read the following carefully before using this project:
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://api.aigocode.dev/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -63,11 +63,6 @@ Please read the following carefully before using this project:
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
|
||||
<td>Thanks to Bestproxy for sponsoring this project! <a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> provides high-purity residential IPs with dedicated one-IP-per-account support. By combining real home networks with fingerprint isolation, it enables link environment isolation and reduces the probability of association-based risk control.</td>
|
||||
@@ -96,18 +91,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
|
||||
@@ -143,11 +126,6 @@ Please read the following carefully before using this project:
|
||||
<td>Swiftproxy is a high-performance proxy solution built for developers, providing stable and reliable residential and static residential proxy services. With 90M+ clean residential IPs, global coverage, flexible rotation, and precise geo-targeting, it helps projects such as web scraping, AI automation, browser automation, SEO monitoring, and multi-account management overcome access restrictions and improve workflow efficiency. It supports HTTP(S) and SOCKS5 protocols, integrates with popular automation tools like Playwright, Selenium, and Puppeteer, with dynamic proxy traffic that never expires until used and free testing available — <a href="https://www.swiftproxy.net/?ref=sub2api">start your free test now</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
|
||||
@@ -168,6 +146,11 @@ Please read the following carefully before using this project:
|
||||
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://modelflare.dev/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
|
||||
<td>Modelflare: Full strength, stable, nothing watered down. Global SOTA models at a lower cost. Operated by US-based Havenbyte LLC, Modelflare supports GPT, Claude, Gemini, Grok, and other leading models through common compatible protocols, plus 40+ image and video models. Cache compensation is a highlight: selected OpenAI groups (price-first, stable, and premium) guarantee daily cache hit rates of 65%, 75%, or 85%, and if eligible requests fall short, Modelflare refunds the difference, visible on the usage dashboard after settlement. Special offers: a single top-up of US$20 unlocks the GPT discount group at a 0.015x price multiplier, and a single top-up of US$50 unlocks the Claude discount group at a 0.03x multiplier. Register via <a href="https://modelflare.dev/sign-up?utm_source=github&utm_campaign=sub2api">this exclusive link</a> to get started.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
@@ -354,8 +337,10 @@ JWT_SECRET=your_jwt_secret_here
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# Optional: Admin account
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
# Leave empty to auto-generate a random email (login username) and password, shown in logs on first startup.
|
||||
# Avoid guessable values such as admin@example.com: they are brute-force targets.
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Optional: Custom port
|
||||
SERVER_PORT=8080
|
||||
@@ -404,9 +389,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
|
||||
Open `http://YOUR_SERVER_IP:8080` in your browser.
|
||||
|
||||
If admin password was auto-generated, find it in logs:
|
||||
If the admin email (login username) or password was auto-generated, find them in logs:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
|
||||
```
|
||||
|
||||
#### Upgrade
|
||||
@@ -827,6 +812,31 @@ Administrators can override automatic media eligibility through the account crea
|
||||
|
||||
---
|
||||
|
||||
## TypeSafe / Jev Support
|
||||
|
||||
Sub2API supports TypeSafe API-key accounts through Jev's native, non-streaming System One protocol.
|
||||
|
||||
- Platform: `typesafe`; account type: API Key
|
||||
- Default upstream: `https://api.typesafe.ai`
|
||||
- Public endpoint: `POST /v1/systemone`
|
||||
- Model: `jev-latest`, also returned by `/v1/models` for TypeSafe groups
|
||||
- Questions: `noul`, `choice`, and `score`
|
||||
|
||||
Requests and successful responses retain the native System One JSON structure. This endpoint is not compatible with Chat Completions, Responses, Anthropic Messages, or streaming clients.
|
||||
|
||||
Question validation follows the TypeSafe OpenAPI wire schema (also used by SDK v0.5.7). `instructions` may be omitted or `null` for all question types. Noul `criteria` may be omitted or `null`; its `true`/`false` descriptions and Choice descriptions accept strings, objects, arrays, or `null`. Score `criteria` must be a non-empty array of string, object, or array descriptions; a single level is valid. SDK integer-keyed Score maps are normalized to arrays by the SDK before sending.
|
||||
|
||||
```bash
|
||||
curl https://your-sub2api.example.com/v1/systemone \
|
||||
-H 'Authorization: Bearer sk-your-sub2api-key' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"model":"jev-latest","state":"Text to evaluate","questions":{"safety":{"type":"noul","instructions":"Evaluate whether the text is unsafe"}}}'
|
||||
```
|
||||
|
||||
The built-in `jev-latest` price is `$0.042` per million input tokens and `$0` for output tokens. Channel pricing can override both values. Credential, billing, permission, rate-limit, overload, server, and network failures (`401`, `402`, `403`, `429`, `529`, `5xx`, transport errors) use the existing account error policy (including custom error codes and temporary-unschedulable rules) and fail over to another account; request errors (`400`, `413`, and `422`) are returned without retrying another account and never change account state. TypeSafe groups (and Composite requests routed to TypeSafe) reject Messages, Chat Completions, Responses, and count_tokens requests with `404`.
|
||||
|
||||
---
|
||||
|
||||
## Antigravity Support
|
||||
|
||||
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
|
||||
|
||||
+38
-28
@@ -55,8 +55,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://api.aigocode.dev/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -64,11 +64,6 @@
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
|
||||
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
|
||||
@@ -99,18 +94,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
|
||||
@@ -146,11 +129,6 @@
|
||||
<td>Swiftproxy 是面向开发者的高性能代理解决方案,提供稳定可靠的住宅代理和静态住宅代理服务。拥有 9000 万+ 纯净住宅 IP,覆盖全球,支持灵活轮换和精准地理定位,帮助网页抓取、AI 自动化、浏览器自动化、SEO 监控和多账号管理等项目突破访问限制,提升工作流效率。支持 HTTP(S) 和 SOCKS5 协议,兼容 Playwright、Selenium、Puppeteer 等主流自动化工具,动态代理流量用完为止永不过期,支持免费测试 — <a href="https://www.swiftproxy.net/?ref=sub2api">立即开始免费测试</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
|
||||
@@ -171,6 +149,11 @@
|
||||
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://modelflare.dev/zh/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
|
||||
<td>Modelflare:满血稳定不掺水,低成本享受全球 SOTA 模型。站点由美国 LLC 主体 Havenbyte LLC 运营,支持 GPT、Claude、Gemini、Grok 等主流模型和常用兼容协议,还支持 40 多种图片和视频模型。缓存补偿是一大亮点:部分 OpenAI 价格优先、稳定和高级分组提供每日 65%、75%、85% 的缓存命中率保障,符合条件的请求没达到目标的,平台补偿差额,结算后可在用量看板查看。特惠活动:单笔充值 US$20 即可解锁 0.015 倍价格倍率的 GPT 特惠分组,单笔充值 US$50 即可解锁 0.03 倍价格倍率的 Claude 特惠分组。通过<a href="https://modelflare.dev/zh/sign-up?utm_source=github&utm_campaign=sub2api">专属链接</a>注册即可参与</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
@@ -380,8 +363,10 @@ JWT_SECRET=your_jwt_secret_here
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# 可选:管理员账号
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
# 留空则首次启动时自动生成随机邮箱(登录用户名)和密码,并输出到日志。
|
||||
# 请勿使用 admin@example.com 这类可被猜中的值,它们是暴力破解的目标。
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# 可选:自定义端口
|
||||
SERVER_PORT=8080
|
||||
@@ -442,9 +427,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
|
||||
在浏览器中打开 `http://你的服务器IP:8080`
|
||||
|
||||
如果管理员密码是自动生成的,在日志中查找:
|
||||
如果管理员邮箱(登录用户名)或密码是自动生成的,在日志中查找:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
|
||||
```
|
||||
|
||||
#### 升级
|
||||
@@ -743,6 +728,31 @@ OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_gener
|
||||
|
||||
---
|
||||
|
||||
## TypeSafe / Jev 使用说明
|
||||
|
||||
Sub2API 支持使用 TypeSafe API Key 账户,通过 Jev 原生、非流式的 System One 协议调用模型。
|
||||
|
||||
- 平台:`typesafe`;账号类型:API Key
|
||||
- 默认上游:`https://api.typesafe.ai`
|
||||
- 对外端点:`POST /v1/systemone`
|
||||
- 模型:`jev-latest`,TypeSafe 分组的 `/v1/models` 也会返回该模型
|
||||
- 问题类型:`noul`、`choice`、`score`
|
||||
|
||||
请求和成功响应保持 System One 原生 JSON 结构。该端点不兼容 Chat Completions、Responses、Anthropic Messages 或流式客户端。
|
||||
|
||||
问题校验遵循 TypeSafe OpenAPI 的线上协议 schema(SDK v0.5.7 也使用该 schema)。所有问题的 `instructions` 都可以省略或为 `null`。Noul 的 `criteria` 可以省略或为 `null`,其中 `true`/`false` 的描述和 Choice 描述支持字符串、对象、数组或 `null`。Score 的 `criteria` 必须是至少包含一档描述的数组,每档支持字符串、对象或数组;单档也合法。SDK 的整数键 Score 映射会由 SDK 在发送前转换为数组。
|
||||
|
||||
```bash
|
||||
curl https://your-sub2api.example.com/v1/systemone \
|
||||
-H 'Authorization: Bearer sk-your-sub2api-key' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"model":"jev-latest","state":"待评估文本","questions":{"safety":{"type":"noul","instructions":"评估文本是否不安全"}}}'
|
||||
```
|
||||
|
||||
`jev-latest` 内置价格为输入 `$0.042/百万 tokens`、输出 `$0`,渠道定价可以覆盖。凭据、欠费、权限、限流、过载、服务端和网络错误(`401`、`402`、`403`、`429`、`529`、`5xx`、传输错误)沿用现有账号错误策略(含自定义错误码与临时不可调度规则)并切换账号;请求错误(`400`、`413`、`422`)不会切换账号重试,也不会改变账号状态。TypeSafe 分组(以及路由到 TypeSafe 的 Composite 请求)调用 Messages、Chat Completions、Responses、count_tokens 时返回 `404`。
|
||||
|
||||
---
|
||||
|
||||
## Antigravity 使用说明
|
||||
|
||||
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
|
||||
|
||||
+13
-28
@@ -54,8 +54,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
|
||||
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://api.aigocode.dev/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -63,11 +63,6 @@
|
||||
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
|
||||
<td>Bestproxy のご支援に感謝します!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> は高純度の住宅IPを提供し、1アカウント1IP専有をサポートしています。実際の家庭ネットワークとフィンガープリント分離を組み合わせることで、リンク環境の分離を実現し、関連付けによるリスク管理の確率を低減します。</td>
|
||||
@@ -98,18 +93,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
|
||||
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
|
||||
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
|
||||
@@ -145,11 +128,6 @@
|
||||
<td>Swiftproxy は開発者向けの高性能プロキシソリューションで、安定して信頼できるレジデンシャルおよび静的レジデンシャルプロキシサービスを提供します。9,000 万以上のクリーンな住宅 IP を保有し、グローバルカバレッジ、柔軟なローテーション、精密なジオターゲティングにより、Web スクレイピング、AI オートメーション、ブラウザ自動化、SEO モニタリング、マルチアカウント管理などのプロジェクトがアクセス制限を克服し、ワークフロー効率を向上させます。HTTP(S) および SOCKS5 プロトコルに対応し、Playwright、Selenium、Puppeteer などの主要な自動化ツールと統合可能。動的プロキシトラフィックは使い切るまで期限切れなし、無料テストも利用可能 — <a href="https://www.swiftproxy.net/?ref=sub2api">今すぐ無料テストを開始</a>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
|
||||
@@ -170,6 +148,11 @@
|
||||
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://modelflare.dev/ja/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
|
||||
<td>Modelflare:フルスペック、安定、水増しなし。世界の SOTA モデルを、より低いコストで。米国拠点の Havenbyte LLC が運営する Modelflare は、一般的な互換プロトコルを通じて GPT、Claude、Gemini、Grok などの主要モデルに対応し、さらに 40 以上の画像・動画モデルも利用できます。注目はキャッシュ補償です。対象の OpenAI グループ(価格優先・安定・プレミアム)は、1 日のキャッシュヒット率をそれぞれ 65%、75%、85% で保証します。対象リクエストがこの水準に達しない場合、Modelflare が差額を返金し、精算後に利用状況ダッシュボードで確認できます。特別オファー:US$20 を 1 回チャージすると、価格倍率 0.015 倍の GPT 割引グループが解放されます。US$50 を 1 回チャージすると、価格倍率 0.03 倍の Claude 割引グループが解放されます。<a href="https://modelflare.dev/ja/sign-up?utm_source=github&utm_campaign=sub2api">こちらの専用リンク</a>から登録して始めましょう。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
@@ -355,8 +338,10 @@ JWT_SECRET=your_jwt_secret_here
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# オプション: 管理者アカウント
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
# 空欄の場合、初回起動時にランダムなメールアドレス(ログインユーザー名)とパスワードが自動生成され、ログに出力されます。
|
||||
# admin@example.com のような推測されやすい値は総当たり攻撃の標的になるため避けてください。
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# オプション: カスタムポート
|
||||
SERVER_PORT=8080
|
||||
@@ -405,9 +390,9 @@ docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
|
||||
ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
|
||||
|
||||
管理者パスワードが自動生成された場合は、ログで確認できます:
|
||||
管理者メールアドレス(ログインユーザー名)またはパスワードが自動生成された場合は、ログで確認できます:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "Generated admin"
|
||||
```
|
||||
|
||||
#### アップグレード
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -123,6 +123,15 @@ linters:
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# x/net v0.60 deprecated the HTTP/2 compatibility adapters. Keep their
|
||||
# existing per-protocol idle timeout and PING configuration in this CI fix.
|
||||
- path: 'internal/(repository/http_upstream(_http2_keepalive_test)?|server/http)\.go$'
|
||||
linters: [ staticcheck ]
|
||||
text: 'SA1019: (\(golang\.org/x/net/http2\.(Server|Transport)\)\.|golang\.org/x/net/http2\.(ConfigureServer|ConfigureTransports|Server|Transport)\b)'
|
||||
# Upstream libraries can still return the legacy typed GOAWAY error.
|
||||
- path: 'internal/service/openai_codex_models_service(_test)?\.go$'
|
||||
linters: [ staticcheck ]
|
||||
text: 'SA1019: golang\.org/x/net/http2\.GoAwayError\b'
|
||||
# G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的
|
||||
# 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的
|
||||
# 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免,
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.27.0-alpine
|
||||
FROM golang:1.27.2-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.2.10
|
||||
0.2.16
|
||||
|
||||
@@ -104,6 +104,7 @@ func provideCleanup(
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
idempotencyCleanup *service.IdempotencyCleanupService,
|
||||
geminiCachedContent *service.GeminiCachedContentService,
|
||||
batchImageCleanup *service.BatchImageCleanupService,
|
||||
batchImageWorker *service.BatchImageWorkerRuntime,
|
||||
pricing *service.PricingService,
|
||||
@@ -244,6 +245,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"GeminiCachedContentService", func() error {
|
||||
if geminiCachedContent != nil {
|
||||
geminiCachedContent.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"BatchImageCleanupService", func() error {
|
||||
if batchImageCleanup != nil {
|
||||
batchImageCleanup.Stop()
|
||||
|
||||
@@ -288,14 +288,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
openCodeGoUsageService := service.ProvideOpenCodeGoUsageService(accountRepository, httpUpstream, settingService, leaderLockCache, db)
|
||||
claudeResetCreditService := service.NewClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
claudeResetCreditService := service.ProvideClaudeResetCreditService(accountRepository, claudeTokenProvider, proxyRepository, settingService, idempotencyCoordinator, leaderLockCache)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, claudeResetCreditService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
|
||||
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
|
||||
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
|
||||
geminiCachedContentRepository := repository.NewGeminiCachedContentRepository(db)
|
||||
geminiCachedContentService := service.ProvideGeminiCachedContentService(geminiCachedContentRepository, geminiMessagesCompatService, configConfig)
|
||||
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator, geminiCachedContentService)
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
@@ -322,7 +325,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
|
||||
@@ -353,7 +355,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
|
||||
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, geminiCachedContentService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
@@ -413,6 +415,7 @@ func provideCleanup(
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
idempotencyCleanup *service.IdempotencyCleanupService,
|
||||
geminiCachedContent *service.GeminiCachedContentService,
|
||||
batchImageCleanup *service.BatchImageCleanupService,
|
||||
batchImageWorker *service.BatchImageWorkerRuntime,
|
||||
pricing *service.PricingService,
|
||||
@@ -552,6 +555,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"GeminiCachedContentService", func() error {
|
||||
if geminiCachedContent != nil {
|
||||
geminiCachedContent.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"BatchImageCleanupService", func() error {
|
||||
if batchImageCleanup != nil {
|
||||
batchImageCleanup.Stop()
|
||||
|
||||
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
subscriptionExpirySvc,
|
||||
&service.UsageCleanupService{},
|
||||
idempotencyCleanupSvc,
|
||||
service.NewGeminiCachedContentService(nil, nil, cfg),
|
||||
&service.BatchImageCleanupService{},
|
||||
nil, // batchImageWorker
|
||||
pricingSvc,
|
||||
|
||||
+14
-1
@@ -30,6 +30,8 @@ type Group struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
// Description holds the value of the "description" field.
|
||||
Description *string `json:"description,omitempty"`
|
||||
// 名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description
|
||||
I18n domain.GroupI18n `json:"i18n,omitempty"`
|
||||
// RateMultiplier holds the value of the "rate_multiplier" field.
|
||||
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
|
||||
// 是否启用高峰时段倍率
|
||||
@@ -258,7 +260,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
|
||||
case group.FieldI18n, group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
@@ -323,6 +325,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
_m.Description = new(string)
|
||||
*_m.Description = value.String
|
||||
}
|
||||
case group.FieldI18n:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field i18n", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.I18n); err != nil {
|
||||
return fmt.Errorf("unmarshal field i18n: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldRateMultiplier:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field rate_multiplier", values[i])
|
||||
@@ -817,6 +827,9 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("i18n=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.I18n))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("rate_multiplier=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -26,6 +26,8 @@ const (
|
||||
FieldName = "name"
|
||||
// FieldDescription holds the string denoting the description field in the database.
|
||||
FieldDescription = "description"
|
||||
// FieldI18n holds the string denoting the i18n field in the database.
|
||||
FieldI18n = "i18n"
|
||||
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
|
||||
FieldRateMultiplier = "rate_multiplier"
|
||||
// FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database.
|
||||
@@ -228,6 +230,7 @@ var Columns = []string{
|
||||
FieldDeletedAt,
|
||||
FieldName,
|
||||
FieldDescription,
|
||||
FieldI18n,
|
||||
FieldRateMultiplier,
|
||||
FieldPeakRateEnabled,
|
||||
FieldPeakStart,
|
||||
@@ -326,6 +329,8 @@ var (
|
||||
UpdateDefaultUpdatedAt func() time.Time
|
||||
// NameValidator is a validator for the "name" field. It is called by the builders before save.
|
||||
NameValidator func(string) error
|
||||
// DefaultI18n holds the default value on creation for the "i18n" field.
|
||||
DefaultI18n domain.GroupI18n
|
||||
// DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field.
|
||||
DefaultRateMultiplier float64
|
||||
// DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field.
|
||||
|
||||
@@ -92,6 +92,12 @@ func (_c *GroupCreate) SetNillableDescription(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (_c *GroupCreate) SetI18n(v domain.GroupI18n) *GroupCreate {
|
||||
_c.mutation.SetI18n(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (_c *GroupCreate) SetRateMultiplier(v float64) *GroupCreate {
|
||||
_c.mutation.SetRateMultiplier(v)
|
||||
@@ -1047,6 +1053,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultUpdatedAt()
|
||||
_c.mutation.SetUpdatedAt(v)
|
||||
}
|
||||
if _, ok := _c.mutation.I18n(); !ok {
|
||||
v := group.DefaultI18n
|
||||
_c.mutation.SetI18n(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RateMultiplier(); !ok {
|
||||
v := group.DefaultRateMultiplier
|
||||
_c.mutation.SetRateMultiplier(v)
|
||||
@@ -1230,6 +1240,9 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.I18n(); !ok {
|
||||
return &ValidationError{Name: "i18n", err: errors.New(`ent: missing required field "Group.i18n"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RateMultiplier(); !ok {
|
||||
return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)}
|
||||
}
|
||||
@@ -1465,6 +1478,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldDescription, field.TypeString, value)
|
||||
_node.Description = &value
|
||||
}
|
||||
if value, ok := _c.mutation.I18n(); ok {
|
||||
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
|
||||
_node.I18n = value
|
||||
}
|
||||
if value, ok := _c.mutation.RateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
_node.RateMultiplier = value
|
||||
@@ -1925,6 +1942,18 @@ func (u *GroupUpsert) ClearDescription() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (u *GroupUpsert) SetI18n(v domain.GroupI18n) *GroupUpsert {
|
||||
u.Set(group.FieldI18n, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateI18n sets the "i18n" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateI18n() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldI18n)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (u *GroupUpsert) SetRateMultiplier(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldRateMultiplier, v)
|
||||
@@ -3039,6 +3068,20 @@ func (u *GroupUpsertOne) ClearDescription() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (u *GroupUpsertOne) SetI18n(v domain.GroupI18n) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetI18n(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateI18n sets the "i18n" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateI18n() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateI18n()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (u *GroupUpsertOne) SetRateMultiplier(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -4485,6 +4528,20 @@ func (u *GroupUpsertBulk) ClearDescription() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (u *GroupUpsertBulk) SetI18n(v domain.GroupI18n) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetI18n(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateI18n sets the "i18n" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateI18n() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateI18n()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (u *GroupUpsertBulk) SetRateMultiplier(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -97,6 +97,12 @@ func (_u *GroupUpdate) ClearDescription() *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (_u *GroupUpdate) SetI18n(v domain.GroupI18n) *GroupUpdate {
|
||||
_u.mutation.SetI18n(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (_u *GroupUpdate) SetRateMultiplier(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetRateMultiplier()
|
||||
@@ -1581,6 +1587,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(group.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.I18n(); ok {
|
||||
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
@@ -2291,6 +2300,12 @@ func (_u *GroupUpdateOne) ClearDescription() *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (_u *GroupUpdateOne) SetI18n(v domain.GroupI18n) *GroupUpdateOne {
|
||||
_u.mutation.SetI18n(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (_u *GroupUpdateOne) SetRateMultiplier(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetRateMultiplier()
|
||||
@@ -3805,6 +3820,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(group.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.I18n(); ok {
|
||||
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RateMultiplier(); ok {
|
||||
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
|
||||
@@ -908,6 +908,7 @@ var (
|
||||
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "i18n", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "peak_rate_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""},
|
||||
@@ -979,22 +980,22 @@ var (
|
||||
{
|
||||
Name: "group_status",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[12]},
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
},
|
||||
{
|
||||
Name: "group_platform",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
Columns: []*schema.Column{GroupsColumns[15]},
|
||||
},
|
||||
{
|
||||
Name: "group_subscription_type",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[15]},
|
||||
Columns: []*schema.Column{GroupsColumns[16]},
|
||||
},
|
||||
{
|
||||
Name: "group_is_exclusive",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[11]},
|
||||
Columns: []*schema.Column{GroupsColumns[12]},
|
||||
},
|
||||
{
|
||||
Name: "group_deleted_at",
|
||||
@@ -1004,12 +1005,12 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[49]},
|
||||
Columns: []*schema.Column{GroupsColumns[50]},
|
||||
},
|
||||
{
|
||||
Name: "idx_groups_duplicate_operation_id_active",
|
||||
Unique: true,
|
||||
Columns: []*schema.Column{GroupsColumns[13]},
|
||||
Columns: []*schema.Column{GroupsColumns[14]},
|
||||
Annotation: &entsql.IndexAnnotation{
|
||||
Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL",
|
||||
},
|
||||
@@ -1128,6 +1129,7 @@ var (
|
||||
{Name: "amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "pay_amount", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "fee_rate", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "bonus_amount", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "recharge_code", Type: field.TypeString, Size: 64},
|
||||
{Name: "out_trade_no", Type: field.TypeString, Size: 64, Default: ""},
|
||||
{Name: "payment_type", Type: field.TypeString, Size: 30},
|
||||
@@ -1170,7 +1172,7 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "payment_orders_users_payment_orders",
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[39]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[40]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
@@ -1179,7 +1181,7 @@ var (
|
||||
{
|
||||
Name: "paymentorder_out_trade_no",
|
||||
Unique: true,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[8]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[9]},
|
||||
Annotation: &entsql.IndexAnnotation{
|
||||
Where: "out_trade_no <> ''",
|
||||
},
|
||||
@@ -1187,37 +1189,37 @@ var (
|
||||
{
|
||||
Name: "paymentorder_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[39]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[40]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_status",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[21]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[22]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_expires_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[29]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[30]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[37]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[38]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_paid_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[30]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[31]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_payment_type_paid_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[9], PaymentOrdersColumns[30]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[10], PaymentOrdersColumns[31]},
|
||||
},
|
||||
{
|
||||
Name: "paymentorder_order_type",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[14]},
|
||||
Columns: []*schema.Column{PaymentOrdersColumns[15]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+143
-2
@@ -22086,6 +22086,7 @@ type GroupMutation struct {
|
||||
deleted_at *time.Time
|
||||
name *string
|
||||
description *string
|
||||
i18n *domain.GroupI18n
|
||||
rate_multiplier *float64
|
||||
addrate_multiplier *float64
|
||||
peak_rate_enabled *bool
|
||||
@@ -22505,6 +22506,42 @@ func (m *GroupMutation) ResetDescription() {
|
||||
delete(m.clearedFields, group.FieldDescription)
|
||||
}
|
||||
|
||||
// SetI18n sets the "i18n" field.
|
||||
func (m *GroupMutation) SetI18n(di domain.GroupI18n) {
|
||||
m.i18n = &di
|
||||
}
|
||||
|
||||
// I18n returns the value of the "i18n" field in the mutation.
|
||||
func (m *GroupMutation) I18n() (r domain.GroupI18n, exists bool) {
|
||||
v := m.i18n
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldI18n returns the old "i18n" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldI18n(ctx context.Context) (v domain.GroupI18n, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldI18n is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldI18n requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldI18n: %w", err)
|
||||
}
|
||||
return oldValue.I18n, nil
|
||||
}
|
||||
|
||||
// ResetI18n resets all changes to the "i18n" field.
|
||||
func (m *GroupMutation) ResetI18n() {
|
||||
m.i18n = nil
|
||||
}
|
||||
|
||||
// SetRateMultiplier sets the "rate_multiplier" field.
|
||||
func (m *GroupMutation) SetRateMultiplier(f float64) {
|
||||
m.rate_multiplier = &f
|
||||
@@ -25921,7 +25958,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 66)
|
||||
fields := make([]string, 0, 67)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25937,6 +25974,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.description != nil {
|
||||
fields = append(fields, group.FieldDescription)
|
||||
}
|
||||
if m.i18n != nil {
|
||||
fields = append(fields, group.FieldI18n)
|
||||
}
|
||||
if m.rate_multiplier != nil {
|
||||
fields = append(fields, group.FieldRateMultiplier)
|
||||
}
|
||||
@@ -26138,6 +26178,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.Name()
|
||||
case group.FieldDescription:
|
||||
return m.Description()
|
||||
case group.FieldI18n:
|
||||
return m.I18n()
|
||||
case group.FieldRateMultiplier:
|
||||
return m.RateMultiplier()
|
||||
case group.FieldPeakRateEnabled:
|
||||
@@ -26279,6 +26321,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldName(ctx)
|
||||
case group.FieldDescription:
|
||||
return m.OldDescription(ctx)
|
||||
case group.FieldI18n:
|
||||
return m.OldI18n(ctx)
|
||||
case group.FieldRateMultiplier:
|
||||
return m.OldRateMultiplier(ctx)
|
||||
case group.FieldPeakRateEnabled:
|
||||
@@ -26445,6 +26489,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetDescription(v)
|
||||
return nil
|
||||
case group.FieldI18n:
|
||||
v, ok := value.(domain.GroupI18n)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetI18n(v)
|
||||
return nil
|
||||
case group.FieldRateMultiplier:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
@@ -27398,6 +27449,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldDescription:
|
||||
m.ResetDescription()
|
||||
return nil
|
||||
case group.FieldI18n:
|
||||
m.ResetI18n()
|
||||
return nil
|
||||
case group.FieldRateMultiplier:
|
||||
m.ResetRateMultiplier()
|
||||
return nil
|
||||
@@ -30113,6 +30167,8 @@ type PaymentOrderMutation struct {
|
||||
addpay_amount *float64
|
||||
fee_rate *float64
|
||||
addfee_rate *float64
|
||||
bonus_amount *float64
|
||||
addbonus_amount *float64
|
||||
recharge_code *string
|
||||
out_trade_no *string
|
||||
payment_type *string
|
||||
@@ -30580,6 +30636,62 @@ func (m *PaymentOrderMutation) ResetFeeRate() {
|
||||
m.addfee_rate = nil
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (m *PaymentOrderMutation) SetBonusAmount(f float64) {
|
||||
m.bonus_amount = &f
|
||||
m.addbonus_amount = nil
|
||||
}
|
||||
|
||||
// BonusAmount returns the value of the "bonus_amount" field in the mutation.
|
||||
func (m *PaymentOrderMutation) BonusAmount() (r float64, exists bool) {
|
||||
v := m.bonus_amount
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldBonusAmount returns the old "bonus_amount" field's value of the PaymentOrder entity.
|
||||
// If the PaymentOrder object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *PaymentOrderMutation) OldBonusAmount(ctx context.Context) (v float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldBonusAmount is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldBonusAmount requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldBonusAmount: %w", err)
|
||||
}
|
||||
return oldValue.BonusAmount, nil
|
||||
}
|
||||
|
||||
// AddBonusAmount adds f to the "bonus_amount" field.
|
||||
func (m *PaymentOrderMutation) AddBonusAmount(f float64) {
|
||||
if m.addbonus_amount != nil {
|
||||
*m.addbonus_amount += f
|
||||
} else {
|
||||
m.addbonus_amount = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedBonusAmount returns the value that was added to the "bonus_amount" field in this mutation.
|
||||
func (m *PaymentOrderMutation) AddedBonusAmount() (r float64, exists bool) {
|
||||
v := m.addbonus_amount
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ResetBonusAmount resets all changes to the "bonus_amount" field.
|
||||
func (m *PaymentOrderMutation) ResetBonusAmount() {
|
||||
m.bonus_amount = nil
|
||||
m.addbonus_amount = nil
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (m *PaymentOrderMutation) SetRechargeCode(s string) {
|
||||
m.recharge_code = &s
|
||||
@@ -32123,7 +32235,7 @@ func (m *PaymentOrderMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *PaymentOrderMutation) Fields() []string {
|
||||
fields := make([]string, 0, 39)
|
||||
fields := make([]string, 0, 40)
|
||||
if m.user != nil {
|
||||
fields = append(fields, paymentorder.FieldUserID)
|
||||
}
|
||||
@@ -32145,6 +32257,9 @@ func (m *PaymentOrderMutation) Fields() []string {
|
||||
if m.fee_rate != nil {
|
||||
fields = append(fields, paymentorder.FieldFeeRate)
|
||||
}
|
||||
if m.bonus_amount != nil {
|
||||
fields = append(fields, paymentorder.FieldBonusAmount)
|
||||
}
|
||||
if m.recharge_code != nil {
|
||||
fields = append(fields, paymentorder.FieldRechargeCode)
|
||||
}
|
||||
@@ -32263,6 +32378,8 @@ func (m *PaymentOrderMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.PayAmount()
|
||||
case paymentorder.FieldFeeRate:
|
||||
return m.FeeRate()
|
||||
case paymentorder.FieldBonusAmount:
|
||||
return m.BonusAmount()
|
||||
case paymentorder.FieldRechargeCode:
|
||||
return m.RechargeCode()
|
||||
case paymentorder.FieldOutTradeNo:
|
||||
@@ -32350,6 +32467,8 @@ func (m *PaymentOrderMutation) OldField(ctx context.Context, name string) (ent.V
|
||||
return m.OldPayAmount(ctx)
|
||||
case paymentorder.FieldFeeRate:
|
||||
return m.OldFeeRate(ctx)
|
||||
case paymentorder.FieldBonusAmount:
|
||||
return m.OldBonusAmount(ctx)
|
||||
case paymentorder.FieldRechargeCode:
|
||||
return m.OldRechargeCode(ctx)
|
||||
case paymentorder.FieldOutTradeNo:
|
||||
@@ -32472,6 +32591,13 @@ func (m *PaymentOrderMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetFeeRate(v)
|
||||
return nil
|
||||
case paymentorder.FieldBonusAmount:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetBonusAmount(v)
|
||||
return nil
|
||||
case paymentorder.FieldRechargeCode:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
@@ -32713,6 +32839,9 @@ func (m *PaymentOrderMutation) AddedFields() []string {
|
||||
if m.addfee_rate != nil {
|
||||
fields = append(fields, paymentorder.FieldFeeRate)
|
||||
}
|
||||
if m.addbonus_amount != nil {
|
||||
fields = append(fields, paymentorder.FieldBonusAmount)
|
||||
}
|
||||
if m.addplan_id != nil {
|
||||
fields = append(fields, paymentorder.FieldPlanID)
|
||||
}
|
||||
@@ -32739,6 +32868,8 @@ func (m *PaymentOrderMutation) AddedField(name string) (ent.Value, bool) {
|
||||
return m.AddedPayAmount()
|
||||
case paymentorder.FieldFeeRate:
|
||||
return m.AddedFeeRate()
|
||||
case paymentorder.FieldBonusAmount:
|
||||
return m.AddedBonusAmount()
|
||||
case paymentorder.FieldPlanID:
|
||||
return m.AddedPlanID()
|
||||
case paymentorder.FieldSubscriptionGroupID:
|
||||
@@ -32777,6 +32908,13 @@ func (m *PaymentOrderMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddFeeRate(v)
|
||||
return nil
|
||||
case paymentorder.FieldBonusAmount:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddBonusAmount(v)
|
||||
return nil
|
||||
case paymentorder.FieldPlanID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
@@ -32976,6 +33114,9 @@ func (m *PaymentOrderMutation) ResetField(name string) error {
|
||||
case paymentorder.FieldFeeRate:
|
||||
m.ResetFeeRate()
|
||||
return nil
|
||||
case paymentorder.FieldBonusAmount:
|
||||
m.ResetBonusAmount()
|
||||
return nil
|
||||
case paymentorder.FieldRechargeCode:
|
||||
m.ResetRechargeCode()
|
||||
return nil
|
||||
|
||||
@@ -33,6 +33,8 @@ type PaymentOrder struct {
|
||||
PayAmount float64 `json:"pay_amount,omitempty"`
|
||||
// FeeRate holds the value of the "fee_rate" field.
|
||||
FeeRate float64 `json:"fee_rate,omitempty"`
|
||||
// BonusAmount holds the value of the "bonus_amount" field.
|
||||
BonusAmount float64 `json:"bonus_amount,omitempty"`
|
||||
// RechargeCode holds the value of the "recharge_code" field.
|
||||
RechargeCode string `json:"recharge_code,omitempty"`
|
||||
// OutTradeNo holds the value of the "out_trade_no" field.
|
||||
@@ -132,7 +134,7 @@ func (*PaymentOrder) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new([]byte)
|
||||
case paymentorder.FieldForceRefund:
|
||||
values[i] = new(sql.NullBool)
|
||||
case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldRefundAmount:
|
||||
case paymentorder.FieldAmount, paymentorder.FieldPayAmount, paymentorder.FieldFeeRate, paymentorder.FieldBonusAmount, paymentorder.FieldRefundAmount:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case paymentorder.FieldID, paymentorder.FieldUserID, paymentorder.FieldPlanID, paymentorder.FieldSubscriptionGroupID, paymentorder.FieldSubscriptionDays:
|
||||
values[i] = new(sql.NullInt64)
|
||||
@@ -204,6 +206,12 @@ func (_m *PaymentOrder) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.FeeRate = value.Float64
|
||||
}
|
||||
case paymentorder.FieldBonusAmount:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field bonus_amount", values[i])
|
||||
} else if value.Valid {
|
||||
_m.BonusAmount = value.Float64
|
||||
}
|
||||
case paymentorder.FieldRechargeCode:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field recharge_code", values[i])
|
||||
@@ -480,6 +488,9 @@ func (_m *PaymentOrder) String() string {
|
||||
builder.WriteString("fee_rate=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.FeeRate))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("bonus_amount=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.BonusAmount))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("recharge_code=")
|
||||
builder.WriteString(_m.RechargeCode)
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -28,6 +28,8 @@ const (
|
||||
FieldPayAmount = "pay_amount"
|
||||
// FieldFeeRate holds the string denoting the fee_rate field in the database.
|
||||
FieldFeeRate = "fee_rate"
|
||||
// FieldBonusAmount holds the string denoting the bonus_amount field in the database.
|
||||
FieldBonusAmount = "bonus_amount"
|
||||
// FieldRechargeCode holds the string denoting the recharge_code field in the database.
|
||||
FieldRechargeCode = "recharge_code"
|
||||
// FieldOutTradeNo holds the string denoting the out_trade_no field in the database.
|
||||
@@ -115,6 +117,7 @@ var Columns = []string{
|
||||
FieldAmount,
|
||||
FieldPayAmount,
|
||||
FieldFeeRate,
|
||||
FieldBonusAmount,
|
||||
FieldRechargeCode,
|
||||
FieldOutTradeNo,
|
||||
FieldPaymentType,
|
||||
@@ -166,6 +169,8 @@ var (
|
||||
UserNameValidator func(string) error
|
||||
// DefaultFeeRate holds the default value on creation for the "fee_rate" field.
|
||||
DefaultFeeRate float64
|
||||
// DefaultBonusAmount holds the default value on creation for the "bonus_amount" field.
|
||||
DefaultBonusAmount float64
|
||||
// RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save.
|
||||
RechargeCodeValidator func(string) error
|
||||
// DefaultOutTradeNo holds the default value on creation for the "out_trade_no" field.
|
||||
@@ -249,6 +254,11 @@ func ByFeeRate(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldFeeRate, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBonusAmount orders the results by the bonus_amount field.
|
||||
func ByBonusAmount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBonusAmount, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRechargeCode orders the results by the recharge_code field.
|
||||
func ByRechargeCode(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRechargeCode, opts...).ToFunc()
|
||||
|
||||
@@ -90,6 +90,11 @@ func FeeRate(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldEQ(FieldFeeRate, v))
|
||||
}
|
||||
|
||||
// BonusAmount applies equality check predicate on the "bonus_amount" field. It's identical to BonusAmountEQ.
|
||||
func BonusAmount(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// RechargeCode applies equality check predicate on the "recharge_code" field. It's identical to RechargeCodeEQ.
|
||||
func RechargeCode(v string) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v))
|
||||
@@ -590,6 +595,46 @@ func FeeRateLTE(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldLTE(FieldFeeRate, v))
|
||||
}
|
||||
|
||||
// BonusAmountEQ applies the EQ predicate on the "bonus_amount" field.
|
||||
func BonusAmountEQ(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldEQ(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// BonusAmountNEQ applies the NEQ predicate on the "bonus_amount" field.
|
||||
func BonusAmountNEQ(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldNEQ(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// BonusAmountIn applies the In predicate on the "bonus_amount" field.
|
||||
func BonusAmountIn(vs ...float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldIn(FieldBonusAmount, vs...))
|
||||
}
|
||||
|
||||
// BonusAmountNotIn applies the NotIn predicate on the "bonus_amount" field.
|
||||
func BonusAmountNotIn(vs ...float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldNotIn(FieldBonusAmount, vs...))
|
||||
}
|
||||
|
||||
// BonusAmountGT applies the GT predicate on the "bonus_amount" field.
|
||||
func BonusAmountGT(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldGT(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// BonusAmountGTE applies the GTE predicate on the "bonus_amount" field.
|
||||
func BonusAmountGTE(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldGTE(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// BonusAmountLT applies the LT predicate on the "bonus_amount" field.
|
||||
func BonusAmountLT(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldLT(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// BonusAmountLTE applies the LTE predicate on the "bonus_amount" field.
|
||||
func BonusAmountLTE(v float64) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldLTE(FieldBonusAmount, v))
|
||||
}
|
||||
|
||||
// RechargeCodeEQ applies the EQ predicate on the "recharge_code" field.
|
||||
func RechargeCodeEQ(v string) predicate.PaymentOrder {
|
||||
return predicate.PaymentOrder(sql.FieldEQ(FieldRechargeCode, v))
|
||||
|
||||
@@ -81,6 +81,20 @@ func (_c *PaymentOrderCreate) SetNillableFeeRate(v *float64) *PaymentOrderCreate
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (_c *PaymentOrderCreate) SetBonusAmount(v float64) *PaymentOrderCreate {
|
||||
_c.mutation.SetBonusAmount(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
|
||||
func (_c *PaymentOrderCreate) SetNillableBonusAmount(v *float64) *PaymentOrderCreate {
|
||||
if v != nil {
|
||||
_c.SetBonusAmount(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (_c *PaymentOrderCreate) SetRechargeCode(v string) *PaymentOrderCreate {
|
||||
_c.mutation.SetRechargeCode(v)
|
||||
@@ -517,6 +531,10 @@ func (_c *PaymentOrderCreate) defaults() {
|
||||
v := paymentorder.DefaultFeeRate
|
||||
_c.mutation.SetFeeRate(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BonusAmount(); !ok {
|
||||
v := paymentorder.DefaultBonusAmount
|
||||
_c.mutation.SetBonusAmount(v)
|
||||
}
|
||||
if _, ok := _c.mutation.OutTradeNo(); !ok {
|
||||
v := paymentorder.DefaultOutTradeNo
|
||||
_c.mutation.SetOutTradeNo(v)
|
||||
@@ -577,6 +595,9 @@ func (_c *PaymentOrderCreate) check() error {
|
||||
if _, ok := _c.mutation.FeeRate(); !ok {
|
||||
return &ValidationError{Name: "fee_rate", err: errors.New(`ent: missing required field "PaymentOrder.fee_rate"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BonusAmount(); !ok {
|
||||
return &ValidationError{Name: "bonus_amount", err: errors.New(`ent: missing required field "PaymentOrder.bonus_amount"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RechargeCode(); !ok {
|
||||
return &ValidationError{Name: "recharge_code", err: errors.New(`ent: missing required field "PaymentOrder.recharge_code"`)}
|
||||
}
|
||||
@@ -725,6 +746,10 @@ func (_c *PaymentOrderCreate) createSpec() (*PaymentOrder, *sqlgraph.CreateSpec)
|
||||
_spec.SetField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
|
||||
_node.FeeRate = value
|
||||
}
|
||||
if value, ok := _c.mutation.BonusAmount(); ok {
|
||||
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
|
||||
_node.BonusAmount = value
|
||||
}
|
||||
if value, ok := _c.mutation.RechargeCode(); ok {
|
||||
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
|
||||
_node.RechargeCode = value
|
||||
@@ -1030,6 +1055,24 @@ func (u *PaymentOrderUpsert) AddFeeRate(v float64) *PaymentOrderUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsert) SetBonusAmount(v float64) *PaymentOrderUpsert {
|
||||
u.Set(paymentorder.FieldBonusAmount, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
|
||||
func (u *PaymentOrderUpsert) UpdateBonusAmount() *PaymentOrderUpsert {
|
||||
u.SetExcluded(paymentorder.FieldBonusAmount)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddBonusAmount adds v to the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsert) AddBonusAmount(v float64) *PaymentOrderUpsert {
|
||||
u.Add(paymentorder.FieldBonusAmount, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (u *PaymentOrderUpsert) SetRechargeCode(v string) *PaymentOrderUpsert {
|
||||
u.Set(paymentorder.FieldRechargeCode, v)
|
||||
@@ -1711,6 +1754,27 @@ func (u *PaymentOrderUpsertOne) UpdateFeeRate() *PaymentOrderUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsertOne) SetBonusAmount(v float64) *PaymentOrderUpsertOne {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.SetBonusAmount(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddBonusAmount adds v to the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsertOne) AddBonusAmount(v float64) *PaymentOrderUpsertOne {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.AddBonusAmount(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
|
||||
func (u *PaymentOrderUpsertOne) UpdateBonusAmount() *PaymentOrderUpsertOne {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.UpdateBonusAmount()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (u *PaymentOrderUpsertOne) SetRechargeCode(v string) *PaymentOrderUpsertOne {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
@@ -2643,6 +2707,27 @@ func (u *PaymentOrderUpsertBulk) UpdateFeeRate() *PaymentOrderUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsertBulk) SetBonusAmount(v float64) *PaymentOrderUpsertBulk {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.SetBonusAmount(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddBonusAmount adds v to the "bonus_amount" field.
|
||||
func (u *PaymentOrderUpsertBulk) AddBonusAmount(v float64) *PaymentOrderUpsertBulk {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.AddBonusAmount(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateBonusAmount sets the "bonus_amount" field to the value that was provided on create.
|
||||
func (u *PaymentOrderUpsertBulk) UpdateBonusAmount() *PaymentOrderUpsertBulk {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
s.UpdateBonusAmount()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (u *PaymentOrderUpsertBulk) SetRechargeCode(v string) *PaymentOrderUpsertBulk {
|
||||
return u.Update(func(s *PaymentOrderUpsert) {
|
||||
|
||||
@@ -154,6 +154,27 @@ func (_u *PaymentOrderUpdate) AddFeeRate(v float64) *PaymentOrderUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (_u *PaymentOrderUpdate) SetBonusAmount(v float64) *PaymentOrderUpdate {
|
||||
_u.mutation.ResetBonusAmount()
|
||||
_u.mutation.SetBonusAmount(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
|
||||
func (_u *PaymentOrderUpdate) SetNillableBonusAmount(v *float64) *PaymentOrderUpdate {
|
||||
if v != nil {
|
||||
_u.SetBonusAmount(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddBonusAmount adds value to the "bonus_amount" field.
|
||||
func (_u *PaymentOrderUpdate) AddBonusAmount(v float64) *PaymentOrderUpdate {
|
||||
_u.mutation.AddBonusAmount(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (_u *PaymentOrderUpdate) SetRechargeCode(v string) *PaymentOrderUpdate {
|
||||
_u.mutation.SetRechargeCode(v)
|
||||
@@ -881,6 +902,12 @@ func (_u *PaymentOrderUpdate) sqlSave(ctx context.Context) (_node int, err error
|
||||
if value, ok := _u.mutation.AddedFeeRate(); ok {
|
||||
_spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BonusAmount(); ok {
|
||||
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedBonusAmount(); ok {
|
||||
_spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RechargeCode(); ok {
|
||||
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
|
||||
}
|
||||
@@ -1217,6 +1244,27 @@ func (_u *PaymentOrderUpdateOne) AddFeeRate(v float64) *PaymentOrderUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBonusAmount sets the "bonus_amount" field.
|
||||
func (_u *PaymentOrderUpdateOne) SetBonusAmount(v float64) *PaymentOrderUpdateOne {
|
||||
_u.mutation.ResetBonusAmount()
|
||||
_u.mutation.SetBonusAmount(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableBonusAmount sets the "bonus_amount" field if the given value is not nil.
|
||||
func (_u *PaymentOrderUpdateOne) SetNillableBonusAmount(v *float64) *PaymentOrderUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetBonusAmount(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddBonusAmount adds value to the "bonus_amount" field.
|
||||
func (_u *PaymentOrderUpdateOne) AddBonusAmount(v float64) *PaymentOrderUpdateOne {
|
||||
_u.mutation.AddBonusAmount(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRechargeCode sets the "recharge_code" field.
|
||||
func (_u *PaymentOrderUpdateOne) SetRechargeCode(v string) *PaymentOrderUpdateOne {
|
||||
_u.mutation.SetRechargeCode(v)
|
||||
@@ -1974,6 +2022,12 @@ func (_u *PaymentOrderUpdateOne) sqlSave(ctx context.Context) (_node *PaymentOrd
|
||||
if value, ok := _u.mutation.AddedFeeRate(); ok {
|
||||
_spec.AddField(paymentorder.FieldFeeRate, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BonusAmount(); ok {
|
||||
_spec.SetField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedBonusAmount(); ok {
|
||||
_spec.AddField(paymentorder.FieldBonusAmount, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RechargeCode(); ok {
|
||||
_spec.SetField(paymentorder.FieldRechargeCode, field.TypeString, value)
|
||||
}
|
||||
|
||||
@@ -906,7 +906,21 @@ func init() {
|
||||
// compositemodelroute.DefaultTargetPlatform holds the default value on creation for the target_platform field.
|
||||
compositemodelroute.DefaultTargetPlatform = compositemodelrouteDescTargetPlatform.Default.(string)
|
||||
// compositemodelroute.TargetPlatformValidator is a validator for the "target_platform" field. It is called by the builders before save.
|
||||
compositemodelroute.TargetPlatformValidator = compositemodelrouteDescTargetPlatform.Validators[0].(func(string) error)
|
||||
compositemodelroute.TargetPlatformValidator = func() func(string) error {
|
||||
validators := compositemodelrouteDescTargetPlatform.Validators
|
||||
fns := [...]func(string) error{
|
||||
validators[0].(func(string) error),
|
||||
validators[1].(func(string) error),
|
||||
}
|
||||
return func(target_platform string) error {
|
||||
for _, fn := range fns {
|
||||
if err := fn(target_platform); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
// compositemodelrouteDescUpstreamModel is the schema descriptor for upstream_model field.
|
||||
compositemodelrouteDescUpstreamModel := compositemodelrouteFields[4].Descriptor()
|
||||
// compositemodelroute.DefaultUpstreamModel holds the default value on creation for the upstream_model field.
|
||||
@@ -1023,204 +1037,208 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
// groupDescI18n is the schema descriptor for i18n field.
|
||||
groupDescI18n := groupFields[2].Descriptor()
|
||||
// group.DefaultI18n holds the default value on creation for the i18n field.
|
||||
group.DefaultI18n = groupDescI18n.Default.(domain.GroupI18n)
|
||||
// groupDescRateMultiplier is the schema descriptor for rate_multiplier field.
|
||||
groupDescRateMultiplier := groupFields[2].Descriptor()
|
||||
groupDescRateMultiplier := groupFields[3].Descriptor()
|
||||
// group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
|
||||
group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64)
|
||||
// groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field.
|
||||
groupDescPeakRateEnabled := groupFields[3].Descriptor()
|
||||
groupDescPeakRateEnabled := groupFields[4].Descriptor()
|
||||
// group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field.
|
||||
group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool)
|
||||
// groupDescPeakStart is the schema descriptor for peak_start field.
|
||||
groupDescPeakStart := groupFields[4].Descriptor()
|
||||
groupDescPeakStart := groupFields[5].Descriptor()
|
||||
// group.DefaultPeakStart holds the default value on creation for the peak_start field.
|
||||
group.DefaultPeakStart = groupDescPeakStart.Default.(string)
|
||||
// group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
|
||||
group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error)
|
||||
// groupDescPeakEnd is the schema descriptor for peak_end field.
|
||||
groupDescPeakEnd := groupFields[5].Descriptor()
|
||||
groupDescPeakEnd := groupFields[6].Descriptor()
|
||||
// group.DefaultPeakEnd holds the default value on creation for the peak_end field.
|
||||
group.DefaultPeakEnd = groupDescPeakEnd.Default.(string)
|
||||
// group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
|
||||
group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error)
|
||||
// groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field.
|
||||
groupDescPeakRateMultiplier := groupFields[6].Descriptor()
|
||||
groupDescPeakRateMultiplier := groupFields[7].Descriptor()
|
||||
// group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field.
|
||||
group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64)
|
||||
// groupDescIsExclusive is the schema descriptor for is_exclusive field.
|
||||
groupDescIsExclusive := groupFields[7].Descriptor()
|
||||
groupDescIsExclusive := groupFields[8].Descriptor()
|
||||
// group.DefaultIsExclusive holds the default value on creation for the is_exclusive field.
|
||||
group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool)
|
||||
// groupDescStatus is the schema descriptor for status field.
|
||||
groupDescStatus := groupFields[8].Descriptor()
|
||||
groupDescStatus := groupFields[9].Descriptor()
|
||||
// group.DefaultStatus holds the default value on creation for the status field.
|
||||
group.DefaultStatus = groupDescStatus.Default.(string)
|
||||
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
|
||||
// groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field.
|
||||
groupDescDuplicateOperationID := groupFields[9].Descriptor()
|
||||
groupDescDuplicateOperationID := groupFields[10].Descriptor()
|
||||
// group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
|
||||
group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error)
|
||||
// groupDescPlatform is the schema descriptor for platform field.
|
||||
groupDescPlatform := groupFields[10].Descriptor()
|
||||
groupDescPlatform := groupFields[11].Descriptor()
|
||||
// group.DefaultPlatform holds the default value on creation for the platform field.
|
||||
group.DefaultPlatform = groupDescPlatform.Default.(string)
|
||||
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
|
||||
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
|
||||
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
|
||||
groupDescSubscriptionType := groupFields[11].Descriptor()
|
||||
groupDescSubscriptionType := groupFields[12].Descriptor()
|
||||
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
|
||||
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
|
||||
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
|
||||
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
|
||||
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
|
||||
groupDescDefaultValidityDays := groupFields[15].Descriptor()
|
||||
groupDescDefaultValidityDays := groupFields[16].Descriptor()
|
||||
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
|
||||
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
|
||||
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
|
||||
groupDescAllowImageGeneration := groupFields[16].Descriptor()
|
||||
groupDescAllowImageGeneration := groupFields[17].Descriptor()
|
||||
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
|
||||
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
|
||||
// groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field.
|
||||
groupDescAllowBatchImageGeneration := groupFields[17].Descriptor()
|
||||
groupDescAllowBatchImageGeneration := groupFields[18].Descriptor()
|
||||
// group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field.
|
||||
group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool)
|
||||
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
|
||||
groupDescImageRateIndependent := groupFields[18].Descriptor()
|
||||
groupDescImageRateIndependent := groupFields[19].Descriptor()
|
||||
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
|
||||
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
|
||||
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
|
||||
groupDescImageRateMultiplier := groupFields[19].Descriptor()
|
||||
groupDescImageRateMultiplier := groupFields[20].Descriptor()
|
||||
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
|
||||
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
|
||||
// groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field.
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor()
|
||||
groupDescBatchImageDiscountMultiplier := groupFields[24].Descriptor()
|
||||
// group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field.
|
||||
group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64)
|
||||
// groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field.
|
||||
groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor()
|
||||
groupDescBatchImageHoldMultiplier := groupFields[25].Descriptor()
|
||||
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
|
||||
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
|
||||
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
|
||||
groupDescVideoRateIndependent := groupFields[25].Descriptor()
|
||||
groupDescVideoRateIndependent := groupFields[26].Descriptor()
|
||||
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
|
||||
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
|
||||
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
|
||||
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
|
||||
groupDescVideoRateMultiplier := groupFields[27].Descriptor()
|
||||
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
|
||||
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
|
||||
// groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field.
|
||||
groupDescSearchPricePer1k := groupFields[32].Descriptor()
|
||||
groupDescSearchPricePer1k := groupFields[33].Descriptor()
|
||||
// group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
|
||||
group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error)
|
||||
// groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field.
|
||||
groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor()
|
||||
groupDescAudioRealtimePricePerMin := groupFields[34].Descriptor()
|
||||
// group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
|
||||
group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error)
|
||||
// groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field.
|
||||
groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor()
|
||||
groupDescAudioTtsPricePerMillionChars := groupFields[35].Descriptor()
|
||||
// group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
|
||||
group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error)
|
||||
// groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field.
|
||||
groupDescAudioSttPricePerHour := groupFields[35].Descriptor()
|
||||
groupDescAudioSttPricePerHour := groupFields[36].Descriptor()
|
||||
// group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
|
||||
group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error)
|
||||
// groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field.
|
||||
groupDescLongContextPricingEnabled := groupFields[36].Descriptor()
|
||||
groupDescLongContextPricingEnabled := groupFields[37].Descriptor()
|
||||
// group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field.
|
||||
group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[38].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[39].Descriptor()
|
||||
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
|
||||
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
|
||||
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
|
||||
groupDescModelRoutingEnabled := groupFields[42].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[43].Descriptor()
|
||||
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
|
||||
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
|
||||
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
|
||||
groupDescMcpXMLInject := groupFields[43].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[44].Descriptor()
|
||||
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
|
||||
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
|
||||
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
|
||||
groupDescSupportedModelScopes := groupFields[44].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[45].Descriptor()
|
||||
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
|
||||
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
|
||||
// groupDescSortOrder is the schema descriptor for sort_order field.
|
||||
groupDescSortOrder := groupFields[45].Descriptor()
|
||||
groupDescSortOrder := groupFields[46].Descriptor()
|
||||
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
|
||||
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
|
||||
groupDescAllowMessagesDispatch := groupFields[46].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[47].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescAllowLive is the schema descriptor for allow_live field.
|
||||
groupDescAllowLive := groupFields[47].Descriptor()
|
||||
groupDescAllowLive := groupFields[48].Descriptor()
|
||||
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
|
||||
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
|
||||
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
|
||||
groupDescForceOpenaiFast := groupFields[48].Descriptor()
|
||||
groupDescForceOpenaiFast := groupFields[49].Descriptor()
|
||||
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
|
||||
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
|
||||
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
|
||||
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
|
||||
groupDescFreeOpenaiFast := groupFields[50].Descriptor()
|
||||
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
|
||||
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[50].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[51].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[51].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[52].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[52].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[53].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[54].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
|
||||
groupDescModelAllowlist := groupFields[54].Descriptor()
|
||||
groupDescModelAllowlist := groupFields[55].Descriptor()
|
||||
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
|
||||
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
|
||||
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
|
||||
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
|
||||
groupDescCodexModelsManifestConfig := groupFields[56].Descriptor()
|
||||
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
|
||||
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[56].Descriptor()
|
||||
groupDescRpmLimit := groupFields[57].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[58].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[59].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[60].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[60].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[61].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[61].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[62].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[63].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
@@ -1323,70 +1341,74 @@ func init() {
|
||||
paymentorderDescFeeRate := paymentorderFields[6].Descriptor()
|
||||
// paymentorder.DefaultFeeRate holds the default value on creation for the fee_rate field.
|
||||
paymentorder.DefaultFeeRate = paymentorderDescFeeRate.Default.(float64)
|
||||
// paymentorderDescBonusAmount is the schema descriptor for bonus_amount field.
|
||||
paymentorderDescBonusAmount := paymentorderFields[7].Descriptor()
|
||||
// paymentorder.DefaultBonusAmount holds the default value on creation for the bonus_amount field.
|
||||
paymentorder.DefaultBonusAmount = paymentorderDescBonusAmount.Default.(float64)
|
||||
// paymentorderDescRechargeCode is the schema descriptor for recharge_code field.
|
||||
paymentorderDescRechargeCode := paymentorderFields[7].Descriptor()
|
||||
paymentorderDescRechargeCode := paymentorderFields[8].Descriptor()
|
||||
// paymentorder.RechargeCodeValidator is a validator for the "recharge_code" field. It is called by the builders before save.
|
||||
paymentorder.RechargeCodeValidator = paymentorderDescRechargeCode.Validators[0].(func(string) error)
|
||||
// paymentorderDescOutTradeNo is the schema descriptor for out_trade_no field.
|
||||
paymentorderDescOutTradeNo := paymentorderFields[8].Descriptor()
|
||||
paymentorderDescOutTradeNo := paymentorderFields[9].Descriptor()
|
||||
// paymentorder.DefaultOutTradeNo holds the default value on creation for the out_trade_no field.
|
||||
paymentorder.DefaultOutTradeNo = paymentorderDescOutTradeNo.Default.(string)
|
||||
// paymentorder.OutTradeNoValidator is a validator for the "out_trade_no" field. It is called by the builders before save.
|
||||
paymentorder.OutTradeNoValidator = paymentorderDescOutTradeNo.Validators[0].(func(string) error)
|
||||
// paymentorderDescPaymentType is the schema descriptor for payment_type field.
|
||||
paymentorderDescPaymentType := paymentorderFields[9].Descriptor()
|
||||
paymentorderDescPaymentType := paymentorderFields[10].Descriptor()
|
||||
// paymentorder.PaymentTypeValidator is a validator for the "payment_type" field. It is called by the builders before save.
|
||||
paymentorder.PaymentTypeValidator = paymentorderDescPaymentType.Validators[0].(func(string) error)
|
||||
// paymentorderDescPaymentTradeNo is the schema descriptor for payment_trade_no field.
|
||||
paymentorderDescPaymentTradeNo := paymentorderFields[10].Descriptor()
|
||||
paymentorderDescPaymentTradeNo := paymentorderFields[11].Descriptor()
|
||||
// paymentorder.PaymentTradeNoValidator is a validator for the "payment_trade_no" field. It is called by the builders before save.
|
||||
paymentorder.PaymentTradeNoValidator = paymentorderDescPaymentTradeNo.Validators[0].(func(string) error)
|
||||
// paymentorderDescOrderType is the schema descriptor for order_type field.
|
||||
paymentorderDescOrderType := paymentorderFields[14].Descriptor()
|
||||
paymentorderDescOrderType := paymentorderFields[15].Descriptor()
|
||||
// paymentorder.DefaultOrderType holds the default value on creation for the order_type field.
|
||||
paymentorder.DefaultOrderType = paymentorderDescOrderType.Default.(string)
|
||||
// paymentorder.OrderTypeValidator is a validator for the "order_type" field. It is called by the builders before save.
|
||||
paymentorder.OrderTypeValidator = paymentorderDescOrderType.Validators[0].(func(string) error)
|
||||
// paymentorderDescProviderInstanceID is the schema descriptor for provider_instance_id field.
|
||||
paymentorderDescProviderInstanceID := paymentorderFields[18].Descriptor()
|
||||
paymentorderDescProviderInstanceID := paymentorderFields[19].Descriptor()
|
||||
// paymentorder.ProviderInstanceIDValidator is a validator for the "provider_instance_id" field. It is called by the builders before save.
|
||||
paymentorder.ProviderInstanceIDValidator = paymentorderDescProviderInstanceID.Validators[0].(func(string) error)
|
||||
// paymentorderDescProviderKey is the schema descriptor for provider_key field.
|
||||
paymentorderDescProviderKey := paymentorderFields[19].Descriptor()
|
||||
paymentorderDescProviderKey := paymentorderFields[20].Descriptor()
|
||||
// paymentorder.ProviderKeyValidator is a validator for the "provider_key" field. It is called by the builders before save.
|
||||
paymentorder.ProviderKeyValidator = paymentorderDescProviderKey.Validators[0].(func(string) error)
|
||||
// paymentorderDescStatus is the schema descriptor for status field.
|
||||
paymentorderDescStatus := paymentorderFields[21].Descriptor()
|
||||
paymentorderDescStatus := paymentorderFields[22].Descriptor()
|
||||
// paymentorder.DefaultStatus holds the default value on creation for the status field.
|
||||
paymentorder.DefaultStatus = paymentorderDescStatus.Default.(string)
|
||||
// paymentorder.StatusValidator is a validator for the "status" field. It is called by the builders before save.
|
||||
paymentorder.StatusValidator = paymentorderDescStatus.Validators[0].(func(string) error)
|
||||
// paymentorderDescRefundAmount is the schema descriptor for refund_amount field.
|
||||
paymentorderDescRefundAmount := paymentorderFields[22].Descriptor()
|
||||
paymentorderDescRefundAmount := paymentorderFields[23].Descriptor()
|
||||
// paymentorder.DefaultRefundAmount holds the default value on creation for the refund_amount field.
|
||||
paymentorder.DefaultRefundAmount = paymentorderDescRefundAmount.Default.(float64)
|
||||
// paymentorderDescForceRefund is the schema descriptor for force_refund field.
|
||||
paymentorderDescForceRefund := paymentorderFields[25].Descriptor()
|
||||
paymentorderDescForceRefund := paymentorderFields[26].Descriptor()
|
||||
// paymentorder.DefaultForceRefund holds the default value on creation for the force_refund field.
|
||||
paymentorder.DefaultForceRefund = paymentorderDescForceRefund.Default.(bool)
|
||||
// paymentorderDescRefundRequestedBy is the schema descriptor for refund_requested_by field.
|
||||
paymentorderDescRefundRequestedBy := paymentorderFields[28].Descriptor()
|
||||
paymentorderDescRefundRequestedBy := paymentorderFields[29].Descriptor()
|
||||
// paymentorder.RefundRequestedByValidator is a validator for the "refund_requested_by" field. It is called by the builders before save.
|
||||
paymentorder.RefundRequestedByValidator = paymentorderDescRefundRequestedBy.Validators[0].(func(string) error)
|
||||
// paymentorderDescClientIP is the schema descriptor for client_ip field.
|
||||
paymentorderDescClientIP := paymentorderFields[34].Descriptor()
|
||||
paymentorderDescClientIP := paymentorderFields[35].Descriptor()
|
||||
// paymentorder.ClientIPValidator is a validator for the "client_ip" field. It is called by the builders before save.
|
||||
paymentorder.ClientIPValidator = paymentorderDescClientIP.Validators[0].(func(string) error)
|
||||
// paymentorderDescSrcHost is the schema descriptor for src_host field.
|
||||
paymentorderDescSrcHost := paymentorderFields[35].Descriptor()
|
||||
paymentorderDescSrcHost := paymentorderFields[36].Descriptor()
|
||||
// paymentorder.SrcHostValidator is a validator for the "src_host" field. It is called by the builders before save.
|
||||
paymentorder.SrcHostValidator = paymentorderDescSrcHost.Validators[0].(func(string) error)
|
||||
// paymentorderDescCreatedAt is the schema descriptor for created_at field.
|
||||
paymentorderDescCreatedAt := paymentorderFields[37].Descriptor()
|
||||
paymentorderDescCreatedAt := paymentorderFields[38].Descriptor()
|
||||
// paymentorder.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
paymentorder.DefaultCreatedAt = paymentorderDescCreatedAt.Default.(func() time.Time)
|
||||
// paymentorderDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
paymentorderDescUpdatedAt := paymentorderFields[38].Descriptor()
|
||||
paymentorderDescUpdatedAt := paymentorderFields[39].Descriptor()
|
||||
// paymentorder.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
paymentorder.DefaultUpdatedAt = paymentorderDescUpdatedAt.Default.(func() time.Time)
|
||||
// paymentorder.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
@@ -45,6 +47,13 @@ func (CompositeModelRoute) Fields() []ent.Field {
|
||||
field.String("target_platform").
|
||||
MaxLen(50).
|
||||
Default(domain.PlatformOpenAI).
|
||||
Validate(func(s string) error {
|
||||
// 目标平台须为平台清单中的具体平台;数据库不再维护 CHECK 约束。
|
||||
if !domain.IsConcretePlatform(s) {
|
||||
return fmt.Errorf("target_platform %q is not a concrete platform", s)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Comment("Concrete provider platform."),
|
||||
field.String("upstream_model").
|
||||
MaxLen(200).
|
||||
|
||||
@@ -44,6 +44,11 @@ func (Group) Fields() []ent.Field {
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
// 名称与描述的界面语言译文 (added by migration 242)
|
||||
field.JSON("i18n", domain.GroupI18n{}).
|
||||
Default(domain.GroupI18n{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description"),
|
||||
field.Float("rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0),
|
||||
|
||||
@@ -50,6 +50,10 @@ func (PaymentOrder) Fields() []ent.Field {
|
||||
field.Float("fee_rate").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0),
|
||||
// 充值赠送额度(USD)。已计入 amount;单独记录用于订单展示与推广返利基数剔除。
|
||||
field.Float("bonus_amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
|
||||
Default(0),
|
||||
field.String("recharge_code").
|
||||
MaxLen(64),
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
)
|
||||
|
||||
// UserPlatformQuota holds the schema definition for per-user per-platform quota.
|
||||
@@ -38,15 +39,12 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
MaxLen(32).
|
||||
NotEmpty().
|
||||
Validate(func(s string) error {
|
||||
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
|
||||
return nil
|
||||
default:
|
||||
// 平台白名单为平台清单(domain/platforms.go)中的全部具体平台,
|
||||
// 与 service.AllowedQuotaPlatforms 同源;数据库不再维护 CHECK 约束。
|
||||
if !domain.IsConcretePlatform(s) {
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
// 日 / 周 / 月 USD 上限:
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
module github.com/Wei-Shaw/sub2api
|
||||
|
||||
go 1.27.0
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
entgo.io/ent v0.14.5
|
||||
@@ -19,6 +19,7 @@ require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/dgraph-io/ristretto v0.2.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-playground/validator/v10 v10.14.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -50,12 +51,12 @@ require (
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
github.com/zeromicro/go-zero v1.9.4
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.41.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/mod v0.41.0
|
||||
golang.org/x/net v0.60.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/term v0.46.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -120,7 +121,6 @@ require (
|
||||
github.com/go-openapi/inflect v0.19.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
@@ -201,10 +201,10 @@ require (
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
golang.org/x/tools v0.50.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
|
||||
@@ -564,6 +564,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
@@ -580,6 +582,8 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -603,6 +607,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -616,6 +622,8 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -649,6 +657,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -663,6 +673,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -675,6 +687,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -691,6 +705,10 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -922,6 +922,29 @@ type BillingConfig struct {
|
||||
// UserPlatformQuotaSentinelTTLSeconds sentinel(无 limit 占位)entry 的 TTL,
|
||||
// 显著短于 quota cache 默认 86400s 以控 Redis 内存;默认 3600=1h。
|
||||
UserPlatformQuotaSentinelTTLSeconds int `mapstructure:"user_platform_quota_sentinel_ttl_seconds"`
|
||||
// InflightReservation 余额模式在途请求预留(Redis),防止并发请求在预检时看到同一份余额而集体透支。
|
||||
InflightReservation InflightReservationConfig `mapstructure:"inflight_reservation"`
|
||||
}
|
||||
|
||||
// InflightReservationConfig 余额模式在途预留配置。
|
||||
// 准入时按 输入估算 + 输出单价 × max_tokens 估算单请求费用,在 Redis 中原子地
|
||||
// 校验 缓存余额 - 在途预留合计 >= 估算 后登记预留,请求结束(任意路径)释放。
|
||||
// 估算失败或 Redis 不可用时 fail-open,退回旧的仅余额 > 阈值检查。
|
||||
type InflightReservationConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
// TTLSeconds 单条预留的最长存活时间;进程崩溃等泄漏的预留到期自动失效。
|
||||
TTLSeconds int `mapstructure:"ttl_seconds"`
|
||||
// DefaultMaxTokens 请求未携带 max_tokens 时用于估算的输出 token 数。
|
||||
DefaultMaxTokens int `mapstructure:"default_max_tokens"`
|
||||
// MaxOutputTokens 估算输出 token 的上限(max_tokens 超出时截断)。
|
||||
MaxOutputTokens int `mapstructure:"max_output_tokens"`
|
||||
// MaxInputTokens 输入 token 估算(请求体字节数 / 4)的上限。
|
||||
MaxInputTokens int `mapstructure:"max_input_tokens"`
|
||||
// MaxReservationUSD 单请求预留金额上限;0 表示不设上限。
|
||||
MaxReservationUSD float64 `mapstructure:"max_reservation_usd"`
|
||||
// FailClosedOnUnpriced 无法为请求估算费用(模型/分组/渠道均无定价)时是否拒绝请求。
|
||||
// 默认 false:放行且不预留(fail-open,节流告警日志)。
|
||||
FailClosedOnUnpriced bool `mapstructure:"fail_closed_on_unpriced"`
|
||||
}
|
||||
|
||||
type CircuitBreakerConfig struct {
|
||||
@@ -982,6 +1005,8 @@ type GatewayConfig struct {
|
||||
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
|
||||
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
|
||||
GeminiDebugResponseHeaders bool `mapstructure:"gemini_debug_response_headers"`
|
||||
// GeminiCachedContentMaxTTLSeconds: Gemini 显式缓存单次可设置的最长有效期(秒),<=0 时按 86400 处理
|
||||
GeminiCachedContentMaxTTLSeconds int `mapstructure:"gemini_cached_content_max_ttl_seconds"`
|
||||
// ConnectionPoolIsolation: 上游连接池隔离策略(proxy/account/account_proxy)
|
||||
ConnectionPoolIsolation string `mapstructure:"connection_pool_isolation"`
|
||||
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
|
||||
@@ -2067,6 +2092,7 @@ func setDefaults() {
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"api.kimi.com",
|
||||
"api.kimi.ai",
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
"open.bigmodel.cn",
|
||||
@@ -2102,6 +2128,13 @@ func setDefaults() {
|
||||
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
|
||||
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
|
||||
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
|
||||
viper.SetDefault("billing.inflight_reservation.enabled", true)
|
||||
viper.SetDefault("billing.inflight_reservation.ttl_seconds", 900)
|
||||
viper.SetDefault("billing.inflight_reservation.default_max_tokens", 8192)
|
||||
viper.SetDefault("billing.inflight_reservation.max_output_tokens", 128000)
|
||||
viper.SetDefault("billing.inflight_reservation.max_input_tokens", 200000)
|
||||
viper.SetDefault("billing.inflight_reservation.max_reservation_usd", 0)
|
||||
viper.SetDefault("billing.inflight_reservation.fail_closed_on_unpriced", false)
|
||||
|
||||
// Turnstile
|
||||
viper.SetDefault("turnstile.required", false)
|
||||
@@ -2497,6 +2530,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
|
||||
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
|
||||
viper.SetDefault("gateway.gemini_debug_response_headers", false)
|
||||
viper.SetDefault("gateway.gemini_cached_content_max_ttl_seconds", 86400)
|
||||
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
|
||||
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
|
||||
viper.SetDefault("gateway.max_idle_conns", 2560) // 最大空闲连接总数(高并发场景可调大)
|
||||
@@ -3079,6 +3113,11 @@ func (c *Config) Validate() error {
|
||||
if c.Billing.MinimumBalanceReserve < 0 {
|
||||
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
|
||||
}
|
||||
if c.Billing.InflightReservation.TTLSeconds < 0 || c.Billing.InflightReservation.DefaultMaxTokens < 0 ||
|
||||
c.Billing.InflightReservation.MaxOutputTokens < 0 || c.Billing.InflightReservation.MaxInputTokens < 0 ||
|
||||
c.Billing.InflightReservation.MaxReservationUSD < 0 {
|
||||
return fmt.Errorf("billing.inflight_reservation values must be non-negative")
|
||||
}
|
||||
if c.Database.MaxOpenConns <= 0 {
|
||||
return fmt.Errorf("database.max_open_conns must be positive")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -831,6 +832,7 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
|
||||
|
||||
wantHosts := []string{
|
||||
"api.kimi.com",
|
||||
"api.kimi.ai",
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
}
|
||||
@@ -843,6 +845,19 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
|
||||
t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts)
|
||||
}
|
||||
}
|
||||
opts := urlvalidator.ValidationOptions{
|
||||
AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||
}
|
||||
for _, host := range []string{"api.kimi.com", "api.kimi.ai"} {
|
||||
target := "https://" + host + "/coding/v1/usages"
|
||||
normalized, err := urlvalidator.ValidateHTTPSURL(target, opts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, target, normalized)
|
||||
_, err = urlvalidator.ValidateHTTPSURL("https://"+host+".attacker.example/coding/v1/usages", opts)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultServerMode(t *testing.T) {
|
||||
|
||||
@@ -28,10 +28,17 @@ const (
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||
PlatformTypeSafe = "typesafe" // TypeSafe AI System One (Jev)
|
||||
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
|
||||
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
|
||||
// 值保持 opencode_go 以兼容已落库的账号、分组、配额与 Composite 路由。
|
||||
PlatformOpenCodeGo = "opencode_go"
|
||||
PlatformComposite = "composite"
|
||||
// PlatformCommandCode 是 Command Code Provider API(多模型聚合,积分计费,
|
||||
// 订阅套餐另有 5 小时 / 每周滚动窗口)。
|
||||
PlatformCommandCode = "command_code"
|
||||
// PlatformCline 是 Cline API(多模型聚合,OpenAI 兼容 Chat Completions;按量积分计费,
|
||||
// 另有 ClinePass 订阅)。
|
||||
PlatformCline = "cline"
|
||||
PlatformComposite = "composite"
|
||||
)
|
||||
|
||||
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GroupLocaleText 是分组名称与描述在某个界面语言下的译文。
|
||||
// 字段为空表示该语言沿用分组自身的 name/description。
|
||||
type GroupLocaleText struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// GroupI18n 以界面语言代码(如 "en"、"zh")为键保存分组展示文案的译文。
|
||||
// 仅用于页面展示;分组的唯一性、搜索与各类日志仍以 name 为准。
|
||||
type GroupI18n map[string]GroupLocaleText
|
||||
|
||||
// Localize 返回 name、description 在请求首选语言下的版本;该语言没有译文的字段保持原值。
|
||||
// acceptLanguage 是 Accept-Language 请求头。只采用其中优先级最高的语言:原字段的语言
|
||||
// 未知,退到次选语言的译文不一定比原字段更合适。
|
||||
func (m GroupI18n) Localize(acceptLanguage, name, description string) (string, string) {
|
||||
text, ok := m.lookup(preferredLanguage(acceptLanguage))
|
||||
if !ok {
|
||||
return name, description
|
||||
}
|
||||
if text.Name != "" {
|
||||
name = text.Name
|
||||
}
|
||||
if text.Description != "" {
|
||||
description = text.Description
|
||||
}
|
||||
return name, description
|
||||
}
|
||||
|
||||
// LocalizeName 是只需要名称时的 Localize。
|
||||
func (m GroupI18n) LocalizeName(acceptLanguage, name string) string {
|
||||
name, _ = m.Localize(acceptLanguage, name, "")
|
||||
return name
|
||||
}
|
||||
|
||||
// CanonicalLocale 按 BCP 47 的惯用大小写书写语言代码:主语言小写,4 位文字代码首字母大写,
|
||||
// 2 位地区代码大写,其余小写(zh-hant-tw -> zh-Hant-TW);单字母扩展标记之后的部分全部小写。
|
||||
// 只调整大小写,不校验代码是否合法。
|
||||
func CanonicalLocale(locale string) string {
|
||||
subtags := strings.Split(locale, "-")
|
||||
extension := false
|
||||
for i, subtag := range subtags {
|
||||
subtag = strings.ToLower(subtag)
|
||||
switch {
|
||||
case i == 0 || extension:
|
||||
case len(subtag) == 1:
|
||||
extension = true
|
||||
case len(subtag) == 2:
|
||||
subtag = strings.ToUpper(subtag)
|
||||
case len(subtag) == 4:
|
||||
subtag = strings.ToUpper(subtag[:1]) + subtag[1:]
|
||||
}
|
||||
subtags[i] = subtag
|
||||
}
|
||||
return strings.Join(subtags, "-")
|
||||
}
|
||||
|
||||
// lookup 先按完整语言代码匹配(zh-TW),再按主语言匹配(zh-CN -> zh),不区分大小写。
|
||||
// 写入时语言代码已统一为 CanonicalLocale,同一语言只有一个键;若存有仅大小写不同的多个键,
|
||||
// 规范写法的键优先,其余按字节序取最小者,保证同一请求语言的结果固定。
|
||||
func (m GroupI18n) lookup(tag string) (GroupLocaleText, bool) {
|
||||
if tag == "" || len(m) == 0 {
|
||||
return GroupLocaleText{}, false
|
||||
}
|
||||
primary, _, _ := strings.Cut(tag, "-")
|
||||
for _, candidate := range []string{tag, primary} {
|
||||
candidate = CanonicalLocale(candidate)
|
||||
if text, ok := m[candidate]; ok {
|
||||
return text, true
|
||||
}
|
||||
matched := ""
|
||||
for locale := range m {
|
||||
if strings.EqualFold(locale, candidate) && (matched == "" || locale < matched) {
|
||||
matched = locale
|
||||
}
|
||||
}
|
||||
if matched != "" {
|
||||
return m[matched], true
|
||||
}
|
||||
}
|
||||
return GroupLocaleText{}, false
|
||||
}
|
||||
|
||||
// preferredLanguage 取 Accept-Language 中权重最高的语言代码,权重相同取靠前者;
|
||||
// 通配符 * 与 q=0 的条目不参与。
|
||||
func preferredLanguage(acceptLanguage string) string {
|
||||
best, bestQ := "", 0.0
|
||||
for _, part := range strings.Split(acceptLanguage, ",") {
|
||||
tag, params, _ := strings.Cut(part, ";")
|
||||
tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")
|
||||
if tag == "" || tag == "*" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
if value, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
q = parsed
|
||||
}
|
||||
if q > bestQ {
|
||||
best, bestQ = tag, q
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGroupI18nLocalize(t *testing.T) {
|
||||
i18n := GroupI18n{
|
||||
"en": {Name: "Standard", Description: "Pro + Max pool"},
|
||||
"zh-TW": {Name: "標準"},
|
||||
"ja": {Description: "説明のみ"},
|
||||
}
|
||||
const name, description = "标准", "号池"
|
||||
|
||||
tests := []struct {
|
||||
title string
|
||||
acceptLanguage string
|
||||
wantName string
|
||||
wantDescription string
|
||||
}{
|
||||
{"exact locale", "en", "Standard", "Pro + Max pool"},
|
||||
{"region falls back to the primary language", "en-US", "Standard", "Pro + Max pool"},
|
||||
{"locale match ignores case and underscore", "ZH_tw", "標準", description},
|
||||
{"a missing field keeps the original text", "ja", name, "説明のみ"},
|
||||
{"no translation for the language keeps both", "zh", name, description},
|
||||
{"primary language does not match a regional translation", "zh-CN", name, description},
|
||||
{"empty header keeps both", "", name, description},
|
||||
{"wildcard only keeps both", "*", name, description},
|
||||
// 原字段的语言未知,不退到次选语言:浏览器的 zh 用户不应拿到英文译文。
|
||||
{"only the preferred language is considered", "zh-CN,zh;q=0.9,en;q=0.8", name, description},
|
||||
{"highest weight wins over position", "zh;q=0.5, en;q=0.9", "Standard", "Pro + Max pool"},
|
||||
{"equal weight keeps the first", "en, zh", "Standard", "Pro + Max pool"},
|
||||
{"entries with q=0 are ignored", "en;q=0, ja", name, "説明のみ"},
|
||||
{"malformed weight is skipped", "en;q=abc, ja", name, "説明のみ"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.title, func(t *testing.T) {
|
||||
gotName, gotDescription := i18n.Localize(tt.acceptLanguage, name, description)
|
||||
if gotName != tt.wantName || gotDescription != tt.wantDescription {
|
||||
t.Fatalf("Localize(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.acceptLanguage, gotName, gotDescription, tt.wantName, tt.wantDescription)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupI18nLocalize_NilMap(t *testing.T) {
|
||||
var i18n GroupI18n
|
||||
|
||||
name, description := i18n.Localize("en", "标准", "号池")
|
||||
|
||||
if name != "标准" || description != "号池" {
|
||||
t.Fatalf("nil translations must keep the original text, got (%q, %q)", name, description)
|
||||
}
|
||||
if got := i18n.LocalizeName("en", "标准"); got != "标准" {
|
||||
t.Fatalf("LocalizeName on nil translations = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupI18nLocalizeName(t *testing.T) {
|
||||
i18n := GroupI18n{"en": {Name: "Standard"}}
|
||||
|
||||
if got := i18n.LocalizeName("en", "标准"); got != "Standard" {
|
||||
t.Fatalf("LocalizeName(en) = %q", got)
|
||||
}
|
||||
if got := i18n.LocalizeName("zh", "标准"); got != "标准" {
|
||||
t.Fatalf("LocalizeName(zh) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalLocale(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"en": "en",
|
||||
"EN": "en",
|
||||
"zh-tw": "zh-TW",
|
||||
"ZH-Hant-tw": "zh-Hant-TW",
|
||||
"es-419": "es-419",
|
||||
"de-CH-1901": "de-CH-1901",
|
||||
"en-US-x-Twain": "en-US-x-twain",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := CanonicalLocale(in); got != want {
|
||||
t.Errorf("CanonicalLocale(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 写入路径已拒绝仅大小写不同的语言代码;已存在的此类数据读取时也必须得到固定结果。
|
||||
func TestGroupI18nLocalize_CaseCollidingKeysAreDeterministic(t *testing.T) {
|
||||
tests := []struct {
|
||||
title string
|
||||
i18n GroupI18n
|
||||
acceptLanguage string
|
||||
want string
|
||||
}{
|
||||
{"canonical key wins", GroupI18n{"EN": {Name: "B"}, "en": {Name: "A"}, "En": {Name: "C"}}, "en", "A"},
|
||||
{"otherwise the smallest key wins", GroupI18n{"eN": {Name: "B"}, "EN": {Name: "A"}}, "en", "A"},
|
||||
{"primary language fallback is deterministic too", GroupI18n{"ZH": {Name: "B"}, "zh": {Name: "A"}}, "zh-CN", "A"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.title, func(t *testing.T) {
|
||||
for range 200 {
|
||||
if got := tt.i18n.LocalizeName(tt.acceptLanguage, "原名"); got != tt.want {
|
||||
t.Fatalf("LocalizeName(%q) = %q, want %q", tt.acceptLanguage, got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package domain
|
||||
|
||||
// 平台清单:平台的单一数据来源。"所有平台"类列表(分组平台、用户额度、组合
|
||||
// 路由目标、调度快照、定价匹配等)与"某网关族"类列表都从这里派生;新增平台
|
||||
// 登记一条即可在这些位置生效,无需逐处修改或新增数据库约束迁移。
|
||||
//
|
||||
// 依赖具体实现的能力(OAuth、渠道监控、余额 / Coding Plan 探测、用量窗口、
|
||||
// Codex 模型目录等)仍由各自的代码显式列出,不在此处声明。
|
||||
|
||||
// PlatformGateway 标识平台请求由哪个网关服务处理。
|
||||
type PlatformGateway string
|
||||
|
||||
const (
|
||||
// PlatformGatewayAnthropic 由 GatewayService 处理(Claude Messages 协议,以及 TypeSafe 的 /v1/systemone)。
|
||||
PlatformGatewayAnthropic PlatformGateway = "anthropic"
|
||||
// PlatformGatewayOpenAI 由 OpenAIGatewayService 处理(OpenAI、Grok 与多协议 API Key 供应商)。
|
||||
PlatformGatewayOpenAI PlatformGateway = "openai"
|
||||
// PlatformGatewayGemini 由 Gemini 兼容网关处理。
|
||||
PlatformGatewayGemini PlatformGateway = "gemini"
|
||||
// PlatformGatewayAntigravity 由 Antigravity 网关处理。
|
||||
PlatformGatewayAntigravity PlatformGateway = "antigravity"
|
||||
)
|
||||
|
||||
// PlatformSpec 描述一个具体(非 composite)平台。
|
||||
type PlatformSpec struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Gateway PlatformGateway `json:"gateway"`
|
||||
// CNProvider 标识国产单一厂商(Kimi / 智谱 / DeepSeek / MiniMax),共享国产
|
||||
// 供应商的余额 / Coding Plan 等能力。
|
||||
CNProvider bool `json:"cn_provider"`
|
||||
// LiteLLMProvider 是定价目录中的 provider 名;空表示不支持从定价目录同步模型。
|
||||
LiteLLMProvider string `json:"litellm_provider,omitempty"`
|
||||
}
|
||||
|
||||
// platformList 的顺序即平台的展示顺序。
|
||||
var platformList = []PlatformSpec{
|
||||
{ID: PlatformAnthropic, DisplayName: "Anthropic", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "anthropic"},
|
||||
{ID: PlatformOpenAI, DisplayName: "OpenAI", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "openai"},
|
||||
{ID: PlatformGemini, DisplayName: "Gemini", Gateway: PlatformGatewayGemini, LiteLLMProvider: "gemini"},
|
||||
{ID: PlatformAntigravity, DisplayName: "Antigravity", Gateway: PlatformGatewayAntigravity, LiteLLMProvider: "anthropic"},
|
||||
{ID: PlatformGrok, DisplayName: "Grok", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "xai"},
|
||||
{ID: PlatformKimi, DisplayName: "Kimi", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "moonshot"},
|
||||
{ID: PlatformZhipu, DisplayName: "Zhipu GLM", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "zhipu"},
|
||||
{ID: PlatformDeepseek, DisplayName: "DeepSeek", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "deepseek"},
|
||||
{ID: PlatformMiniMax, DisplayName: "MiniMax", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "minimax"},
|
||||
{ID: PlatformOpenCodeGo, DisplayName: "OpenCode", Gateway: PlatformGatewayOpenAI, LiteLLMProvider: "opencode-go"},
|
||||
{ID: PlatformTypeSafe, DisplayName: "TypeSafe / Jev", Gateway: PlatformGatewayAnthropic, LiteLLMProvider: "typesafe"},
|
||||
{ID: PlatformCommandCode, DisplayName: "Command Code", Gateway: PlatformGatewayOpenAI},
|
||||
{ID: PlatformCline, DisplayName: "Cline", Gateway: PlatformGatewayOpenAI},
|
||||
}
|
||||
|
||||
var platformIndex = func() map[string]int {
|
||||
index := make(map[string]int, len(platformList))
|
||||
for i, spec := range platformList {
|
||||
index[spec.ID] = i
|
||||
}
|
||||
return index
|
||||
}()
|
||||
|
||||
// Platforms 返回全部具体平台(副本),按展示顺序。
|
||||
func Platforms() []PlatformSpec {
|
||||
out := make([]PlatformSpec, len(platformList))
|
||||
copy(out, platformList)
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupPlatform 返回具体平台的登记信息;composite 与未知值返回 false。
|
||||
func LookupPlatform(id string) (PlatformSpec, bool) {
|
||||
i, ok := platformIndex[id]
|
||||
if !ok {
|
||||
return PlatformSpec{}, false
|
||||
}
|
||||
return platformList[i], true
|
||||
}
|
||||
|
||||
// IsConcretePlatform 报告 id 是否为已登记的具体平台(不含 composite)。
|
||||
func IsConcretePlatform(id string) bool {
|
||||
_, ok := platformIndex[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsGroupPlatform 报告 id 是否可作为分组平台(具体平台或 composite)。
|
||||
func IsGroupPlatform(id string) bool {
|
||||
return id == PlatformComposite || IsConcretePlatform(id)
|
||||
}
|
||||
|
||||
// ConcretePlatformIDs 返回全部具体平台标识,按展示顺序。
|
||||
func ConcretePlatformIDs() []string {
|
||||
return PlatformIDsWhere(func(PlatformSpec) bool { return true })
|
||||
}
|
||||
|
||||
// PlatformIDsWhere 返回满足条件的具体平台标识,按展示顺序。
|
||||
func PlatformIDsWhere(match func(PlatformSpec) bool) []string {
|
||||
out := make([]string, 0, len(platformList))
|
||||
for _, spec := range platformList {
|
||||
if match(spec) {
|
||||
out = append(out, spec.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compositePrecedenceHead 是组合分组查找顺序的固定前缀(历史顺序:Gemini 先于 OpenAI)。
|
||||
var compositePrecedenceHead = []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI}
|
||||
|
||||
// CompositePrecedencePlatformIDs 返回按平台依次回退时的顺序:组合分组在请求目标
|
||||
// 解析前逐平台查找定价 / 模型映射(先命中者生效)、拼接默认模型列表,以及调度快照
|
||||
// 遍历。沿用历史顺序 anthropic、gemini、openai,其余按展示顺序,新登记的平台追加在末尾。
|
||||
func CompositePrecedencePlatformIDs() []string {
|
||||
out := make([]string, 0, len(platformList))
|
||||
seen := make(map[string]struct{}, len(platformList))
|
||||
for _, id := range compositePrecedenceHead {
|
||||
if IsConcretePlatform(id) {
|
||||
out = append(out, id)
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, spec := range platformList {
|
||||
if _, ok := seen[spec.ID]; !ok {
|
||||
out = append(out, spec.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UsesOpenAIGateway 报告平台是否由 OpenAI 网关处理。
|
||||
func UsesOpenAIGateway(id string) bool {
|
||||
spec, ok := LookupPlatform(id)
|
||||
return ok && spec.Gateway == PlatformGatewayOpenAI
|
||||
}
|
||||
|
||||
// IsCNProviderPlatform 报告平台是否为国产单一厂商。
|
||||
func IsCNProviderPlatform(id string) bool {
|
||||
spec, ok := LookupPlatform(id)
|
||||
return ok && spec.CNProvider
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 重构前各处手写的平台列表,作为平台清单派生结果的等价基准;重构后新登记的平台
|
||||
// (Command Code、Cline)按同类平台(OpenCode)的位置补入。
|
||||
var (
|
||||
legacyDisplayOrder = []string{
|
||||
PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
|
||||
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
|
||||
PlatformCommandCode,
|
||||
PlatformCline,
|
||||
}
|
||||
legacyCompositePrecedence = []string{
|
||||
PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok,
|
||||
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, PlatformTypeSafe,
|
||||
PlatformCommandCode,
|
||||
PlatformCline,
|
||||
}
|
||||
legacyOpenAIGateway = []string{
|
||||
PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
|
||||
PlatformCommandCode,
|
||||
PlatformCline,
|
||||
}
|
||||
legacyCNProviders = []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
|
||||
legacyLiteLLMByPlatf = map[string]string{
|
||||
PlatformAnthropic: "anthropic", PlatformOpenAI: "openai", PlatformGemini: "gemini",
|
||||
PlatformAntigravity: "anthropic", PlatformGrok: "xai", PlatformKimi: "moonshot",
|
||||
PlatformZhipu: "zhipu", PlatformDeepseek: "deepseek", PlatformMiniMax: "minimax",
|
||||
PlatformOpenCodeGo: "opencode-go", PlatformTypeSafe: "typesafe", PlatformCommandCode: "", PlatformCline: "",
|
||||
}
|
||||
)
|
||||
|
||||
func TestPlatformListMatchesLegacyLists(t *testing.T) {
|
||||
require.Equal(t, legacyDisplayOrder, ConcretePlatformIDs())
|
||||
require.Equal(t, legacyCompositePrecedence, CompositePrecedencePlatformIDs())
|
||||
require.Equal(t, legacyOpenAIGateway, PlatformIDsWhere(func(spec PlatformSpec) bool {
|
||||
return spec.Gateway == PlatformGatewayOpenAI
|
||||
}))
|
||||
require.Equal(t, legacyCNProviders, PlatformIDsWhere(func(spec PlatformSpec) bool { return spec.CNProvider }))
|
||||
for platform, provider := range legacyLiteLLMByPlatf {
|
||||
spec, ok := LookupPlatform(platform)
|
||||
require.True(t, ok, platform)
|
||||
require.Equal(t, provider, spec.LiteLLMProvider, platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformListPredicates(t *testing.T) {
|
||||
for _, platform := range legacyDisplayOrder {
|
||||
require.True(t, IsConcretePlatform(platform), platform)
|
||||
require.True(t, IsGroupPlatform(platform), platform)
|
||||
spec, ok := LookupPlatform(platform)
|
||||
require.True(t, ok, platform)
|
||||
require.NotEmpty(t, spec.DisplayName, platform)
|
||||
require.NotEmpty(t, spec.Gateway, platform)
|
||||
}
|
||||
require.False(t, IsConcretePlatform(PlatformComposite))
|
||||
require.True(t, IsGroupPlatform(PlatformComposite))
|
||||
for _, invalid := range []string{"", "moonshot", "Kimi", "openai ", "glm", "bogus"} {
|
||||
require.False(t, IsConcretePlatform(invalid), invalid)
|
||||
require.False(t, IsGroupPlatform(invalid), invalid)
|
||||
require.False(t, UsesOpenAIGateway(invalid), invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformsReturnsCopy(t *testing.T) {
|
||||
platforms := Platforms()
|
||||
platforms[0].ID = "mutated"
|
||||
require.Equal(t, PlatformAnthropic, Platforms()[0].ID)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
@@ -1225,7 +1226,7 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
// 网关会按"现状即证据"默认走 Responses。
|
||||
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
|
||||
if account == nil || account.Type != service.AccountTypeAPIKey ||
|
||||
(account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
|
||||
(account.Platform != service.PlatformOpenAI && !account.RoutesProtocolByInbound()) {
|
||||
return
|
||||
}
|
||||
if h.accountTestService == nil {
|
||||
@@ -2893,10 +2894,9 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Antigravity accounts: return Claude + Gemini models
|
||||
// Explicit account mappings expose their request-side names to connectivity tests.
|
||||
if account.Platform == service.PlatformAntigravity {
|
||||
// 直接复用 antigravity.DefaultModels(),与 /v1/models 端点保持同步
|
||||
response.Success(c, antigravity.DefaultModels())
|
||||
response.Success(c, antigravityAccountTestModels(account.Credentials["model_mapping"]))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2950,6 +2950,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// TypeSafe accounts serve only the native System One model.
|
||||
if account.IsTypeSafe() {
|
||||
response.Success(c, []claude.Model{{ID: typesafe.JevLatestModel, Type: "model", DisplayName: typesafe.JevLatestModel}})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Claude/Anthropic accounts
|
||||
// For OAuth and Setup-Token accounts: return default models
|
||||
if account.IsOAuth() {
|
||||
@@ -2991,6 +2997,48 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
response.Success(c, models)
|
||||
}
|
||||
|
||||
// antigravityAccountTestModels uses the stored mapping rather than GetModelMapping,
|
||||
// which supplies defaults and compatibility aliases that the administrator did not configure.
|
||||
func antigravityAccountTestModels(rawMapping any) []antigravity.ClaudeModel {
|
||||
var mappedIDs []string
|
||||
switch mapping := rawMapping.(type) {
|
||||
case map[string]any:
|
||||
for id := range mapping {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
mappedIDs = append(mappedIDs, id)
|
||||
}
|
||||
}
|
||||
case map[string]string:
|
||||
for id := range mapping {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
mappedIDs = append(mappedIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(mappedIDs) == 0 {
|
||||
return antigravity.DefaultModels()
|
||||
}
|
||||
|
||||
sort.Strings(mappedIDs)
|
||||
defaultByID := make(map[string]antigravity.ClaudeModel)
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
defaultByID[model.ID] = model
|
||||
}
|
||||
models := make([]antigravity.ClaudeModel, 0, len(mappedIDs))
|
||||
for _, id := range mappedIDs {
|
||||
if model, ok := defaultByID[id]; ok {
|
||||
models = append(models, model)
|
||||
continue
|
||||
}
|
||||
models = append(models, antigravity.ClaudeModel{
|
||||
ID: id,
|
||||
Type: "model",
|
||||
DisplayName: id,
|
||||
})
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
// SyncUpstreamModels handles syncing live supported models from an account's upstream.
|
||||
// POST /api/v1/admin/accounts/:id/models/sync-upstream
|
||||
func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -330,6 +331,49 @@ func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog
|
||||
require.NotContains(t, ids, "gemini-2.5-flash-image")
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_Antigravity(t *testing.T) {
|
||||
defaults := antigravity.DefaultModels()
|
||||
known := defaults[0]
|
||||
custom := antigravity.ClaudeModel{ID: "claude-custom-alias", Type: "model", DisplayName: "claude-custom-alias"}
|
||||
cases := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
want []antigravity.ClaudeModel
|
||||
}{
|
||||
{name: "nil credentials", want: defaults},
|
||||
{name: "missing mapping", credentials: map[string]any{}, want: defaults},
|
||||
{name: "empty JSON mapping", credentials: map[string]any{"model_mapping": map[string]any{}}, want: defaults},
|
||||
{name: "empty string mapping", credentials: map[string]any{"model_mapping": map[string]string{}}, want: defaults},
|
||||
{name: "blank key", credentials: map[string]any{"model_mapping": map[string]any{" ": "upstream-model"}}, want: defaults},
|
||||
{name: "JSON mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]any{
|
||||
known.ID: known.ID, custom.ID: "upstream-model",
|
||||
}}, want: []antigravity.ClaudeModel{custom, known}},
|
||||
{name: "string mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]string{
|
||||
known.ID: known.ID, custom.ID: "upstream-model",
|
||||
}}, want: []antigravity.ClaudeModel{custom, known}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, accountType := range []string{service.AccountTypeOAuth, service.AccountTypeAPIKey} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{ID: 51, Platform: service.PlatformAntigravity, Type: accountType, Credentials: tc.credentials},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
setupAvailableModelsRouter(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/51/models", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data []antigravity.ClaudeModel `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, tc.want, resp.Data)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -526,3 +570,36 @@ func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarnin
|
||||
require.Len(t, resp.Data.Warnings, 1)
|
||||
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetAvailableModels_TypeSafeOnlyReturnsJev(t *testing.T) {
|
||||
for _, credentials := range []map[string]any{
|
||||
{"api_key": "ts-secret"},
|
||||
{"api_key": "ts-secret", "model_mapping": map[string]any{"jev-latest": "jev-latest"}},
|
||||
} {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 46,
|
||||
Name: "typesafe",
|
||||
Platform: service.PlatformTypeSafe,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Credentials: credentials,
|
||||
},
|
||||
}
|
||||
router := setupAvailableModelsRouter(svc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Len(t, resp.Data, 1)
|
||||
require.Equal(t, "jev-latest", resp.Data[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -631,19 +632,14 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// platformToLiteLLMProvider maps a channel platform name to the corresponding
|
||||
// LiteLLM provider string used as the key in the pricing catalog.
|
||||
var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformAnthropic: "anthropic",
|
||||
service.PlatformOpenAI: "openai",
|
||||
service.PlatformGemini: "gemini",
|
||||
service.PlatformAntigravity: "anthropic",
|
||||
service.PlatformGrok: "xai",
|
||||
service.PlatformKimi: "moonshot",
|
||||
service.PlatformZhipu: "zhipu",
|
||||
service.PlatformDeepseek: "deepseek",
|
||||
service.PlatformMiniMax: "minimax",
|
||||
service.PlatformOpenCodeGo: "opencode-go",
|
||||
// platformLiteLLMProvider maps a channel platform name to the corresponding
|
||||
// LiteLLM provider string used as the key in the pricing catalog (platform list).
|
||||
func platformLiteLLMProvider(platform string) (string, bool) {
|
||||
spec, ok := domain.LookupPlatform(platform)
|
||||
if !ok || spec.LiteLLMProvider == "" {
|
||||
return "", false
|
||||
}
|
||||
return spec.LiteLLMProvider, true
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
@@ -656,7 +652,7 @@ func (h *ChannelHandler) SyncPricingModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
provider, ok := platformToLiteLLMProvider[platform]
|
||||
provider, ok := platformLiteLLMProvider(platform)
|
||||
if !ok {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("UNSUPPORTED_PLATFORM",
|
||||
fmt.Sprintf("unsupported platform: %s", platform)).
|
||||
|
||||
@@ -565,7 +565,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
svc := service.NewPricingService(nil, nil)
|
||||
router := setupSyncPricingModelsRouter(svc)
|
||||
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax", "typesafe"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type claudeResetReader interface {
|
||||
Query(context.Context, int64) (*service.ClaudeResetCredits, error)
|
||||
Redeem(context.Context, int64, string) (*service.ClaudeResetOutcome, error)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetClaudeResetCreditService(s *service.ClaudeResetCreditService) {
|
||||
@@ -32,3 +33,24 @@ func (h *AccountHandler) ClaudeResetCredits(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, status)
|
||||
}
|
||||
|
||||
// RedeemClaudeResetCredit consumes the upstream next reset credit. The request has
|
||||
// no body: the server picks the grant; the Idempotency-Key header identifies one
|
||||
// operator confirmation and replays its outcome.
|
||||
func (h *AccountHandler) RedeemClaudeResetCredit(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.claudeResetCredits == nil {
|
||||
response.Error(c, 503, "Claude reset service unavailable")
|
||||
return
|
||||
}
|
||||
result, err := h.claudeResetCredits.Redeem(c.Request.Context(), id, c.GetHeader("Idempotency-Key"))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type claudeResetHandlerStub struct {
|
||||
key string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *claudeResetHandlerStub) Query(context.Context, int64) (*service.ClaudeResetCredits, error) {
|
||||
return &service.ClaudeResetCredits{Credits: []service.ClaudeResetCredit{}}, nil
|
||||
}
|
||||
|
||||
func (s *claudeResetHandlerStub) Redeem(_ context.Context, _ int64, key string) (*service.ClaudeResetOutcome, error) {
|
||||
s.calls++
|
||||
s.key = key
|
||||
if key == "" {
|
||||
return nil, service.ErrIdempotencyKeyRequired
|
||||
}
|
||||
return &service.ClaudeResetOutcome{Outcome: "reset", Cleared: []string{"five_hour"}}, nil
|
||||
}
|
||||
|
||||
func TestClaudeResetHandlerRedeemContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &claudeResetHandlerStub{}
|
||||
h := &AccountHandler{claudeResetCredits: stub}
|
||||
r := gin.New()
|
||||
r.POST("/accounts/:id/claude/reset-credits/redeem", h.RedeemClaudeResetCredit)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil)
|
||||
req.Header.Set("Idempotency-Key", "same-operation")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "same-operation", stub.key)
|
||||
require.Contains(t, w.Body.String(), `"outcome":"reset"`)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/1/claude/reset-credits/redeem", nil))
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "IDEMPOTENCY_KEY_REQUIRED")
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/accounts/abc/claude/reset-credits/redeem", nil))
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Equal(t, 2, stub.calls)
|
||||
}
|
||||
@@ -142,6 +142,8 @@ type simpleModeGroupResponse struct {
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
|
||||
I18n service.GroupI18n `json:"i18n,omitempty"`
|
||||
|
||||
AccountCount int64 `json:"account_count,omitempty"`
|
||||
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
||||
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||
@@ -157,6 +159,7 @@ func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
|
||||
return &simpleModeGroupResponse{
|
||||
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
|
||||
Status: group.Status,
|
||||
I18n: group.I18n,
|
||||
AccountCount: group.AccountCount,
|
||||
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
|
||||
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
|
||||
@@ -167,7 +170,7 @@ func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
|
||||
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n, Platform: req.Platform}
|
||||
allowed.RateMultiplier = 1
|
||||
allowed.SubscriptionType = service.SubscriptionTypeStandard
|
||||
*req = allowed
|
||||
@@ -177,14 +180,15 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
|
||||
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n}
|
||||
}
|
||||
|
||||
// CreateGroupRequest represents create group request
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
|
||||
I18n service.GroupI18n `json:"i18n"` // 名称与描述的界面语言译文
|
||||
Platform string `json:"platform" binding:"omitempty,group_platform"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -258,7 +262,8 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
|
||||
I18n *service.GroupI18n `json:"i18n"` // 缺省不修改,传入则整体替换
|
||||
Platform string `json:"platform" binding:"omitempty,group_platform"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -332,7 +337,7 @@ type UpdateGroupRequest struct {
|
||||
type CompositeRouteRequest struct {
|
||||
PublicModel string `json:"public_model" binding:"required"`
|
||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,concrete_platform"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||
Priority int `json:"priority"`
|
||||
@@ -664,6 +669,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
I18n: req.I18n,
|
||||
Platform: req.Platform,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
IsExclusive: req.IsExclusive,
|
||||
@@ -809,6 +815,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
I18n: req.I18n,
|
||||
Platform: req.Platform,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
IsExclusive: req.IsExclusive,
|
||||
|
||||
@@ -28,6 +28,8 @@ func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||
allowed := []string{
|
||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
|
||||
"typesafe",
|
||||
"command_code", "cline",
|
||||
}
|
||||
for _, platform := range allowed {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
@@ -71,8 +73,8 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
|
||||
func TestCompositeRouteTargetPlatform_AllowsConcreteProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe", "command_code", "cline"} {
|
||||
var req CompositeRouteRequest
|
||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||
|
||||
@@ -125,6 +125,7 @@ type AdminPaymentOrderResult struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
Currency string `json:"currency"`
|
||||
RechargeCode string `json:"recharge_code,omitempty"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
@@ -182,6 +183,7 @@ func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPayme
|
||||
Amount: order.Amount,
|
||||
PayAmount: order.PayAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
BonusAmount: order.BonusAmount,
|
||||
Currency: service.PaymentOrderCurrency(order),
|
||||
RechargeCode: order.RechargeCode,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// 平台相关的请求绑定校验标签,由平台清单(domain/platforms.go)驱动,替代在
|
||||
// binding 标签里手写 oneof 白名单(新增平台时易漏加,导致分组无法创建):
|
||||
// - group_platform:已登记的具体平台或 composite;
|
||||
// - concrete_platform:已登记的具体平台。
|
||||
func init() {
|
||||
engine, ok := binding.Validator.Engine().(*validator.Validate)
|
||||
if !ok {
|
||||
panic("platform validation requires a go-playground validator engine")
|
||||
}
|
||||
if err := engine.RegisterValidation("group_platform", func(fl validator.FieldLevel) bool {
|
||||
return domain.IsGroupPlatform(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic(fmt.Errorf("register group_platform validation: %w", err))
|
||||
}
|
||||
if err := engine.RegisterValidation("concrete_platform", func(fl validator.FieldLevel) bool {
|
||||
return domain.IsConcretePlatform(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic(fmt.Errorf("register concrete_platform validation: %w", err))
|
||||
}
|
||||
}
|
||||
@@ -362,6 +362,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
PaymentBalanceRechargeMultiplier: paymentCfg.BalanceRechargeMultiplier,
|
||||
PaymentSubscriptionUSDToCNYRate: paymentCfg.SubscriptionUSDToCNYRate,
|
||||
PaymentRechargeFeeRate: paymentCfg.RechargeFeeRate,
|
||||
PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(paymentCfg.RechargeBonusTiers),
|
||||
PaymentRechargeBonusMode: rechargeBonusModeToDTO(paymentCfg.RechargeBonusMode),
|
||||
PaymentRechargeBonusNotice: paymentCfg.RechargeBonusNotice,
|
||||
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
|
||||
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
|
||||
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// rechargeBonusTiersFromDTO 请求 nil 表示未携带该字段(保持现值);空数组表示清空阶梯。
|
||||
func rechargeBonusTiersFromDTO(items *[]dto.RechargeBonusTier) *[]service.RechargeBonusTier {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]service.RechargeBonusTier, 0, len(*items))
|
||||
for _, item := range *items {
|
||||
out = append(out, service.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent})
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// rechargeBonusModeToDTO 输出已归一化的模式(空/非法按 bonus)。
|
||||
func rechargeBonusModeToDTO(mode string) string {
|
||||
normalized, _ := service.NormalizeRechargeBonusMode(mode)
|
||||
return normalized
|
||||
}
|
||||
|
||||
// rechargeBonusTiersToDTO 始终返回非 nil 切片,空配置输出 []。
|
||||
func rechargeBonusTiersToDTO(items []service.RechargeBonusTier) []dto.RechargeBonusTier {
|
||||
out := make([]dto.RechargeBonusTier, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, dto.RechargeBonusTier{MinAmount: item.MinAmount, BonusPercent: item.BonusPercent})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -312,11 +312,15 @@ type UpdateSettingsRequest struct {
|
||||
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
|
||||
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
|
||||
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
|
||||
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
|
||||
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
|
||||
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
|
||||
PaymentHelpImageURL *string `json:"payment_help_image_url"`
|
||||
PaymentHelpText *string `json:"payment_help_text"`
|
||||
// nil 表示不更新;空数组表示清空阶梯
|
||||
PaymentRechargeBonusTiers *[]dto.RechargeBonusTier `json:"payment_recharge_bonus_tiers"`
|
||||
PaymentRechargeBonusMode *string `json:"payment_recharge_bonus_mode"`
|
||||
PaymentRechargeBonusNotice *string `json:"payment_recharge_bonus_notice"`
|
||||
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
|
||||
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
|
||||
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
|
||||
PaymentHelpImageURL *string `json:"payment_help_image_url"`
|
||||
PaymentHelpText *string `json:"payment_help_text"`
|
||||
|
||||
// Cancel rate limit
|
||||
PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"`
|
||||
@@ -2128,6 +2132,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
|
||||
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
|
||||
RechargeFeeRate: req.PaymentRechargeFeeRate,
|
||||
RechargeBonusTiers: rechargeBonusTiersFromDTO(req.PaymentRechargeBonusTiers),
|
||||
RechargeBonusMode: req.PaymentRechargeBonusMode,
|
||||
RechargeBonusNotice: req.PaymentRechargeBonusNotice,
|
||||
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
|
||||
ProductNamePrefix: req.PaymentProductNamePrefix,
|
||||
ProductNameSuffix: req.PaymentProductNameSuffix,
|
||||
@@ -2407,6 +2414,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
|
||||
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
|
||||
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
|
||||
PaymentRechargeBonusTiers: rechargeBonusTiersToDTO(updatedPaymentCfg.RechargeBonusTiers),
|
||||
PaymentRechargeBonusMode: rechargeBonusModeToDTO(updatedPaymentCfg.RechargeBonusMode),
|
||||
PaymentRechargeBonusNotice: updatedPaymentCfg.RechargeBonusNotice,
|
||||
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
|
||||
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
|
||||
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
|
||||
@@ -2483,6 +2493,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
|
||||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
|
||||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
|
||||
req.PaymentRechargeFeeRate != nil ||
|
||||
req.PaymentRechargeBonusTiers != nil || req.PaymentRechargeBonusMode != nil || req.PaymentRechargeBonusNotice != nil ||
|
||||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
|
||||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
|
||||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
|
||||
|
||||
@@ -142,7 +142,7 @@ func (h *APIKeyHandler) List(c *gin.Context) {
|
||||
|
||||
out := make([]dto.APIKey, 0, len(keys))
|
||||
for i := range keys {
|
||||
out = append(out, *dto.APIKeyFromService(&keys[i]))
|
||||
out = append(out, *dto.APIKeyFromService(&keys[i]).Localized(c.GetHeader("Accept-Language")))
|
||||
}
|
||||
response.Paginated(c, out, result.Total, page, pageSize)
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.APIKeyFromService(key))
|
||||
response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")))
|
||||
}
|
||||
|
||||
// Create handles creating a new API key
|
||||
@@ -222,7 +222,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dto.APIKeyFromService(key), nil
|
||||
return dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.APIKeyFromService(key))
|
||||
response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")))
|
||||
}
|
||||
|
||||
// Delete handles deleting an API key
|
||||
@@ -334,7 +334,7 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) {
|
||||
|
||||
out := make([]dto.Group, 0, len(groups))
|
||||
for i := range groups {
|
||||
out = append(out, *dto.GroupFromService(&groups[i]))
|
||||
out = append(out, *dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language")))
|
||||
}
|
||||
response.Success(c, out)
|
||||
}
|
||||
|
||||
@@ -3593,3 +3593,20 @@ func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, erro
|
||||
func (oauthPendingFlowTotpEncryptorStub) Decrypt(ciphertext string) (string, error) {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowEmailCacheStub) IncrVerificationCodeAttempts(_ context.Context, email string) (int, error) {
|
||||
data := s.verificationCodes[email]
|
||||
if data == nil {
|
||||
return 0, errors.New("verification code not found")
|
||||
}
|
||||
data.Attempts++
|
||||
return data.Attempts, nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowEmailCacheStub) IncrNotifyVerifyCodeAttempts(context.Context, string) (int, error) {
|
||||
return 0, errors.New("notify verification code not found")
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowEmailCacheStub) ConsumePasswordResetToken(context.Context, string, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ type userAvailableGroup struct {
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
|
||||
// 不对外输出;List 按请求语言把 Name 换成译文后丢弃。
|
||||
i18n service.GroupI18n
|
||||
}
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
@@ -154,6 +157,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
acceptLanguage := c.GetHeader("Accept-Language")
|
||||
out := make([]userAvailableChannel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
if ch.Status != service.StatusActive {
|
||||
@@ -163,6 +167,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||
if len(visibleGroups) == 0 {
|
||||
continue
|
||||
}
|
||||
localizeUserAvailableGroups(visibleGroups, acceptLanguage)
|
||||
sections := buildPlatformSections(ch, visibleGroups)
|
||||
if len(sections) == 0 {
|
||||
continue
|
||||
@@ -177,6 +182,13 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||
response.Success(c, out)
|
||||
}
|
||||
|
||||
// localizeUserAvailableGroups 把分组名称就地换成请求首选语言的版本。
|
||||
func localizeUserAvailableGroups(groups []userAvailableGroup, acceptLanguage string) {
|
||||
for i := range groups {
|
||||
groups[i].Name = groups[i].i18n.LocalizeName(acceptLanguage, groups[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
|
||||
// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。
|
||||
//
|
||||
@@ -254,6 +266,7 @@ func filterUserVisibleGroups(
|
||||
visible = append(visible, userAvailableGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
i18n: g.I18n,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package dto
|
||||
|
||||
// 用户侧接口在输出前调用 Localized:把分组的名称与描述换成请求首选语言
|
||||
// (Accept-Language)的版本,并去掉译文表。管理端接口不调用,保留原字段与译文表,
|
||||
// 分组编辑表单依赖原字段回写。
|
||||
|
||||
// Localized 就地本地化分组并返回自身。
|
||||
func (g *Group) Localized(acceptLanguage string) *Group {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
g.Name, g.Description = g.I18n.Localize(acceptLanguage, g.Name, g.Description)
|
||||
g.I18n = nil
|
||||
return g
|
||||
}
|
||||
|
||||
// Localized 本地化 API Key 所属分组并返回自身。
|
||||
func (k *APIKey) Localized(acceptLanguage string) *APIKey {
|
||||
if k != nil {
|
||||
k.Group.Localized(acceptLanguage)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// Localized 本地化兑换码关联分组并返回自身。
|
||||
func (r *RedeemCode) Localized(acceptLanguage string) *RedeemCode {
|
||||
if r != nil {
|
||||
r.Group.Localized(acceptLanguage)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Localized 本地化使用记录关联的分组(含其 API Key 与订阅各自携带的分组)并返回自身。
|
||||
func (l *UsageLog) Localized(acceptLanguage string) *UsageLog {
|
||||
if l != nil {
|
||||
l.Group.Localized(acceptLanguage)
|
||||
l.APIKey.Localized(acceptLanguage)
|
||||
l.Subscription.Localized(acceptLanguage)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Localized 本地化订阅关联分组并返回自身。
|
||||
func (s *UserSubscription) Localized(acceptLanguage string) *UserSubscription {
|
||||
if s != nil {
|
||||
s.Group.Localized(acceptLanguage)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func localizableGroup() *Group {
|
||||
return &Group{
|
||||
ID: 1, Name: "标准", Description: "号池",
|
||||
I18n: domain.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupLocalized_ReplacesTextAndDropsTranslations(t *testing.T) {
|
||||
g := localizableGroup().Localized("en")
|
||||
|
||||
require.Equal(t, "Standard", g.Name)
|
||||
require.Equal(t, "Pro + Max pool", g.Description)
|
||||
|
||||
raw, err := json.Marshal(g)
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.NotContains(t, decoded, "i18n", "user-facing output must not carry the translation table")
|
||||
}
|
||||
|
||||
func TestGroupLocalized_KeepsOriginalWhenLanguageHasNoTranslation(t *testing.T) {
|
||||
g := localizableGroup().Localized("zh")
|
||||
|
||||
require.Equal(t, "标准", g.Name)
|
||||
require.Equal(t, "号池", g.Description)
|
||||
require.Nil(t, g.I18n)
|
||||
}
|
||||
|
||||
func TestLocalized_NilSafe(t *testing.T) {
|
||||
require.Nil(t, (*Group)(nil).Localized("en"))
|
||||
require.Nil(t, (*APIKey)(nil).Localized("en"))
|
||||
require.Nil(t, (*RedeemCode)(nil).Localized("en"))
|
||||
require.Nil(t, (*UsageLog)(nil).Localized("en"))
|
||||
require.Nil(t, (*UserSubscription)(nil).Localized("en"))
|
||||
|
||||
// 未携带分组的记录原样返回
|
||||
require.Nil(t, (&APIKey{}).Localized("en").Group)
|
||||
}
|
||||
|
||||
func TestLocalized_NestedGroups(t *testing.T) {
|
||||
key := (&APIKey{Group: localizableGroup()}).Localized("en")
|
||||
require.Equal(t, "Standard", key.Group.Name)
|
||||
|
||||
code := (&RedeemCode{Group: localizableGroup()}).Localized("en")
|
||||
require.Equal(t, "Standard", code.Group.Name)
|
||||
|
||||
sub := (&UserSubscription{Group: localizableGroup()}).Localized("en")
|
||||
require.Equal(t, "Standard", sub.Group.Name)
|
||||
|
||||
// 使用记录除自身分组外,还带着 API Key 与订阅各自的分组
|
||||
log := (&UsageLog{
|
||||
Group: localizableGroup(),
|
||||
APIKey: &APIKey{Group: localizableGroup()},
|
||||
Subscription: &UserSubscription{Group: localizableGroup()},
|
||||
}).Localized("en")
|
||||
require.Equal(t, "Standard", log.Group.Name)
|
||||
require.Equal(t, "Standard", log.APIKey.Group.Name)
|
||||
require.Equal(t, "Standard", log.Subscription.Group.Name)
|
||||
require.Nil(t, log.APIKey.Group.I18n)
|
||||
require.Nil(t, log.Subscription.Group.I18n)
|
||||
}
|
||||
@@ -182,6 +182,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
I18n: g.I18n,
|
||||
Platform: g.Platform,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package dto
|
||||
|
||||
// RechargeBonusTier 充值赠送档位:余额充值支付金额 ≥ MinAmount 时,在到账基数上赠送 BonusPercent%。
|
||||
type RechargeBonusTier struct {
|
||||
MinAmount float64 `json:"min_amount"`
|
||||
BonusPercent float64 `json:"bonus_percent"`
|
||||
}
|
||||
@@ -279,11 +279,15 @@ type SystemSettings struct {
|
||||
PaymentBalanceRechargeMultiplier float64 `json:"payment_balance_recharge_multiplier"`
|
||||
PaymentSubscriptionUSDToCNYRate float64 `json:"payment_subscription_usd_to_cny_rate"`
|
||||
PaymentRechargeFeeRate float64 `json:"payment_recharge_fee_rate"`
|
||||
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
|
||||
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
|
||||
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
|
||||
PaymentHelpImageURL string `json:"payment_help_image_url"`
|
||||
PaymentHelpText string `json:"payment_help_text"`
|
||||
// 充值赠送阶梯与活动文案
|
||||
PaymentRechargeBonusTiers []RechargeBonusTier `json:"payment_recharge_bonus_tiers"`
|
||||
PaymentRechargeBonusMode string `json:"payment_recharge_bonus_mode"`
|
||||
PaymentRechargeBonusNotice string `json:"payment_recharge_bonus_notice"`
|
||||
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
|
||||
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
|
||||
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
|
||||
PaymentHelpImageURL string `json:"payment_help_image_url"`
|
||||
PaymentHelpText string `json:"payment_help_text"`
|
||||
|
||||
// Cancel rate limit
|
||||
PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"`
|
||||
|
||||
@@ -99,6 +99,10 @@ type Group struct {
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// I18n 名称与描述的各语言译文。只有管理端输出;用户侧接口经 Localized 把
|
||||
// name/description 换成请求语言的版本后清空本字段。
|
||||
I18n domain.GroupI18n `json:"i18n,omitempty"`
|
||||
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
DailyLimitUSD *float64 `json:"daily_limit_usd"`
|
||||
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
EndpointMessages = "/v1/messages"
|
||||
EndpointSystemOne = "/v1/systemone"
|
||||
EndpointChatCompletions = "/v1/chat/completions"
|
||||
EndpointEmbeddings = "/v1/embeddings"
|
||||
EndpointAlphaSearch = "/v1/alpha/search"
|
||||
@@ -31,6 +32,7 @@ const (
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
EndpointGeminiCachedContents = "/v1beta/cachedContents"
|
||||
)
|
||||
|
||||
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
|
||||
@@ -52,6 +54,7 @@ const (
|
||||
// "/v1/chat/completions" → "/v1/chat/completions"
|
||||
// "/openai/v1/responses/foo" → "/v1/responses"
|
||||
// "/v1beta/models/gemini:gen" → "/v1beta/models"
|
||||
// "/v1beta/cachedContents/abc" → "/v1beta/cachedContents"
|
||||
//
|
||||
// The OpenAI Responses API is also exposed via a few bare/alias
|
||||
// routes that do not carry a "/v1/" prefix (top-level bare route and
|
||||
@@ -94,6 +97,8 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
return EndpointChatCompletions
|
||||
case strings.Contains(path, EndpointMessages):
|
||||
return EndpointMessages
|
||||
case strings.Contains(path, EndpointSystemOne):
|
||||
return EndpointSystemOne
|
||||
case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"):
|
||||
return EndpointImagesGenerations
|
||||
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
|
||||
@@ -114,6 +119,8 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
return EndpointResponses
|
||||
case strings.Contains(path, EndpointGeminiModels):
|
||||
return EndpointGeminiModels
|
||||
case strings.Contains(path, EndpointGeminiCachedContents):
|
||||
return EndpointGeminiCachedContents
|
||||
default:
|
||||
return path
|
||||
}
|
||||
@@ -191,7 +198,7 @@ func isBareOrSubpathOf(path, root string) bool {
|
||||
// retain their paths. Grok raw Chat requests override this through the
|
||||
// forwarding result consumed by resolveOpenAIUpstreamEndpoint.
|
||||
// - Anthropic → /v1/messages
|
||||
// - Gemini → /v1beta/models
|
||||
// - Gemini → /v1beta/models(显式缓存管理接口保留 /v1beta/cachedContents)
|
||||
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
|
||||
// - Antigravity routes may target either Claude or Gemini, so the
|
||||
// inbound endpoint is used to distinguish.
|
||||
@@ -223,7 +230,13 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||
case service.PlatformAnthropic:
|
||||
return EndpointMessages
|
||||
|
||||
case service.PlatformTypeSafe:
|
||||
return EndpointSystemOne
|
||||
|
||||
case service.PlatformGemini:
|
||||
if inbound == EndpointGeminiCachedContents {
|
||||
return EndpointGeminiCachedContents
|
||||
}
|
||||
return EndpointGeminiModels
|
||||
|
||||
case service.PlatformAntigravity:
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
}{
|
||||
// Direct canonical paths.
|
||||
{"/v1/messages", EndpointMessages},
|
||||
{"/v1/systemone", EndpointSystemOne},
|
||||
{"/v1/chat/completions", EndpointChatCompletions},
|
||||
{"/v1/embeddings", EndpointEmbeddings},
|
||||
{"/v1/alpha/search", EndpointAlphaSearch},
|
||||
@@ -36,6 +37,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
{"/v1/videos/generations", EndpointVideosGenerations},
|
||||
{"/v1/videos/req_123", EndpointVideos},
|
||||
{"/v1beta/models", EndpointGeminiModels},
|
||||
{"/v1beta/cachedContents", EndpointGeminiCachedContents},
|
||||
{"/v1beta/cachedContents/gfo2i2haszm3wbwiyivyok5cyddubfwsr77efg7h", EndpointGeminiCachedContents},
|
||||
|
||||
// Prefixed paths (antigravity, openai) — root Responses.
|
||||
{"/antigravity/v1/messages", EndpointMessages},
|
||||
@@ -97,9 +100,11 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||
}{
|
||||
// Anthropic.
|
||||
{"anthropic messages", EndpointMessages, "/v1/messages", service.PlatformAnthropic, EndpointMessages},
|
||||
{"typesafe system one", EndpointSystemOne, "/v1/systemone", service.PlatformTypeSafe, EndpointSystemOne},
|
||||
|
||||
// Gemini.
|
||||
{"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
|
||||
{"gemini cached contents", EndpointGeminiCachedContents, "/v1beta/cachedContents/abc", service.PlatformGemini, EndpointGeminiCachedContents},
|
||||
|
||||
// OpenAI — root Responses.
|
||||
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
@@ -51,12 +52,14 @@ type GatewayHandler struct {
|
||||
errorPassthroughService *service.ErrorPassthroughService
|
||||
contentModerationService *service.ContentModerationService
|
||||
securityAuditCoordinator *securityaudit.Coordinator
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
userMsgQueueHelper *UserMsgQueueHelper
|
||||
maxAccountSwitches int
|
||||
maxAccountSwitchesGemini int
|
||||
cfg *config.Config
|
||||
settingService *service.SettingService
|
||||
// geminiCachedContentService 管理 Gemini 显式缓存;为 nil 时相关接口不可用。
|
||||
geminiCachedContentService *service.GeminiCachedContentService
|
||||
concurrencyHelper *ConcurrencyHelper
|
||||
userMsgQueueHelper *UserMsgQueueHelper
|
||||
maxAccountSwitches int
|
||||
maxAccountSwitchesGemini int
|
||||
cfg *config.Config
|
||||
settingService *service.SettingService
|
||||
}
|
||||
|
||||
// NewGatewayHandler creates a new GatewayHandler
|
||||
@@ -222,6 +225,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
return
|
||||
}
|
||||
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage {
|
||||
h.anthropicSecurityAuditError(c, decision)
|
||||
@@ -263,6 +269,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if err != nil {
|
||||
reqLog.Info("gateway.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.handleStreamingAwareError(c, status, code, message, streamStarted)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
// 设置请求所属分组 ID(用于渠道级功能判断,如 WebSearch 模拟)
|
||||
parsedReq.GroupID = apiKey.GroupID
|
||||
|
||||
@@ -1149,7 +1168,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
}
|
||||
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
|
||||
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, "", true)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
@@ -1193,6 +1212,10 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
writeGrokModelsList(c, xai.DefaultModelIDs())
|
||||
return
|
||||
}
|
||||
if platform == service.PlatformTypeSafe {
|
||||
writeModelsList(c, platform, []string{typesafe.JevLatestModel})
|
||||
return
|
||||
}
|
||||
|
||||
writeModelsListResponse(c, claude.DefaultModels)
|
||||
}
|
||||
@@ -1244,19 +1267,19 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
platform = group.Platform
|
||||
}
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID, service.CompositeRouteEndpointResponses, false)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
models := availableModels
|
||||
if len(models) == 0 {
|
||||
models = fallbackModels
|
||||
}
|
||||
if group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = fallbackModels
|
||||
}
|
||||
return group.ModelAllowlist.FilterForListing(source)
|
||||
models = group.ModelAllowlist.FilterForListing(models)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
if filtered, err := h.gatewayService.FilterCompositeCodexModels(ctx, group.ID, models); err == nil {
|
||||
return filtered
|
||||
}
|
||||
return fallbackModels
|
||||
return models
|
||||
}
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
@@ -1270,14 +1293,20 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
|
||||
// compositeAvailableModels lists the models the composite group can serve.
|
||||
// includeSystemOne adds TypeSafe models, which only work through /v1/systemone;
|
||||
// LLM client catalogs (Codex) must exclude them.
|
||||
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, endpoint string, includeSystemOne bool) []string {
|
||||
if h == nil || h.gatewayService == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]string, 0)
|
||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
|
||||
for _, platform := range domain.CompositePrecedencePlatformIDs() {
|
||||
if platform == service.PlatformTypeSafe && !includeSystemOne {
|
||||
continue
|
||||
}
|
||||
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
if len(platformModels) == 0 {
|
||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||
@@ -1298,6 +1327,16 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
models = append(models, model)
|
||||
}
|
||||
}
|
||||
// A route can expose a public ID that no account model mapping contains.
|
||||
// On lookup failure, retain the existing account-derived catalog only.
|
||||
if routeModels, err := h.gatewayService.GetCompositeRouteModels(ctx, groupID, endpoint, includeSystemOne); err == nil {
|
||||
for _, model := range routeModels {
|
||||
if _, ok := seen[model]; !ok {
|
||||
seen[model] = struct{}{}
|
||||
models = append(models, model)
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -1461,10 +1500,18 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformOpenCodeGo:
|
||||
return service.DefaultOpenCodeGoModelIDs()
|
||||
case service.PlatformTypeSafe:
|
||||
return []string{"jev-latest"}
|
||||
case service.PlatformComposite:
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
|
||||
for _, concretePlatform := range domain.CompositePrecedencePlatformIDs() {
|
||||
// TypeSafe is deliberately skipped: jev-latest only works through
|
||||
// /v1/systemone, so the static fallback never advertises it to LLM
|
||||
// clients. compositeAvailableModels lists it when the group can serve it.
|
||||
if concretePlatform == service.PlatformTypeSafe {
|
||||
continue
|
||||
}
|
||||
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
@@ -2149,6 +2196,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
return
|
||||
}
|
||||
if rejectSystemOneOnlyPlatform(c, apiKey, h.errorResponse) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
|
||||
@@ -2514,9 +2564,12 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
task, abandon := wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
|
||||
if mode.Dropped() {
|
||||
abandon()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
|
||||
@@ -2544,7 +2597,7 @@ func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context,
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
task, _ = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
|
||||
return
|
||||
|
||||
@@ -82,6 +82,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
return
|
||||
}
|
||||
if rejectSystemOneOnlyPlatform(c, apiKey, h.chatCompletionsErrorResponse) {
|
||||
return
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
@@ -147,6 +150,19 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if err != nil {
|
||||
reqLog.Info("gateway.cc.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.chatCompletionsErrorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
// Parse request for session hash
|
||||
bodyRef := service.NewRequestBodyRef(body)
|
||||
parsedReq, _ := service.ParseGatewayRequest(bodyRef, "chat_completions")
|
||||
|
||||
@@ -82,6 +82,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
return
|
||||
}
|
||||
if rejectSystemOneOnlyPlatform(c, apiKey, h.responsesErrorResponse) {
|
||||
return
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
@@ -151,6 +154,19 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if err != nil {
|
||||
reqLog.Info("gateway.responses.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.responsesErrorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
// Parse request for session hash
|
||||
bodyRef := service.NewRequestBodyRef(body)
|
||||
parsedReq, _ := service.ParseGatewayRequest(bodyRef, "responses")
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// inflightReservationEstimator 估算单请求在途预留金额(USD);false 表示无法定价。
|
||||
type inflightReservationEstimator interface {
|
||||
EstimateInflightReservation(ctx context.Context, apiKey *service.APIKey, req service.InflightEstimateRequest) (float64, bool)
|
||||
}
|
||||
|
||||
// requestMaxOutputTokens 从请求体中提取输出 token 上限(兼容 Anthropic / OpenAI Chat / Responses / Gemini)。
|
||||
func requestMaxOutputTokens(body []byte) int {
|
||||
for _, path := range []string{"max_tokens", "max_completion_tokens", "max_output_tokens", "generationConfig.maxOutputTokens", "generation_config.max_output_tokens"} {
|
||||
if v := gjson.GetBytes(body, path); v.Exists() && v.Type == gjson.Number && v.Int() > 0 {
|
||||
return int(v.Int())
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// tokenInflightEstimate 文本类请求的估算输入。
|
||||
func tokenInflightEstimate(model string, body []byte) service.InflightEstimateRequest {
|
||||
return service.InflightEstimateRequest{
|
||||
Model: model,
|
||||
BodyBytes: len(body),
|
||||
MaxTokens: requestMaxOutputTokens(body),
|
||||
Kind: service.InflightEstimateToken,
|
||||
}
|
||||
}
|
||||
|
||||
func inflightNoop() {}
|
||||
|
||||
// reserveInflightBalance 在 CheckBillingEligibility 之后为余额模式请求登记在途预留。
|
||||
//
|
||||
// 成功时把预留句柄挂到 c.Request 的 context 上:之后通过 submit*UsageRecordTask 提交的
|
||||
// 计费任务会接管一个引用,直到余额缓存实际扣减后才释放。返回的 done 必须 defer 调用
|
||||
// (停止续期并归还 handler 引用;没有提交计费任务时即刻释放)。
|
||||
// 开关关闭、订阅模式、Redis 故障时返回 no-op(fail-open);无法定价时默认 fail-open,
|
||||
// 配置 fail_closed_on_unpriced=true 时返回 ErrInsufficientBalance。
|
||||
func reserveInflightBalance(
|
||||
c *gin.Context,
|
||||
billing *service.BillingCacheService,
|
||||
estimator inflightReservationEstimator,
|
||||
apiKey *service.APIKey,
|
||||
subscription *service.UserSubscription,
|
||||
req service.InflightEstimateRequest,
|
||||
) (func(), error) {
|
||||
if c == nil || c.Request == nil {
|
||||
return inflightNoop, nil
|
||||
}
|
||||
ctx, done, err := reserveInflightBalanceCtx(c.Request.Context(), billing, estimator, apiKey, subscription, req)
|
||||
if err != nil {
|
||||
return inflightNoop, err
|
||||
}
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// reserveInflightBalanceCtx 同 reserveInflightBalance,但返回携带预留句柄的新 context
|
||||
// (供 WebSocket 等自管 context 的路径)。
|
||||
func reserveInflightBalanceCtx(
|
||||
ctx context.Context,
|
||||
billing *service.BillingCacheService,
|
||||
estimator inflightReservationEstimator,
|
||||
apiKey *service.APIKey,
|
||||
subscription *service.UserSubscription,
|
||||
req service.InflightEstimateRequest,
|
||||
) (context.Context, func(), error) {
|
||||
if billing == nil || estimator == nil || apiKey == nil || apiKey.User == nil || !billing.InflightReservationEnabled() {
|
||||
return ctx, inflightNoop, nil
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() && subscription != nil {
|
||||
return ctx, inflightNoop, nil
|
||||
}
|
||||
estimate, priced := estimator.EstimateInflightReservation(ctx, apiKey, req)
|
||||
if !priced && billing.InflightReservationFailClosedOnUnpriced() {
|
||||
return ctx, inflightNoop, service.ErrInsufficientBalance
|
||||
}
|
||||
if estimate <= 0 {
|
||||
return ctx, inflightNoop, nil
|
||||
}
|
||||
res, err := billing.ReserveInflight(ctx, apiKey.User, apiKey.Group, subscription, estimate)
|
||||
if err != nil {
|
||||
return ctx, inflightNoop, err
|
||||
}
|
||||
if res == nil {
|
||||
return ctx, inflightNoop, nil
|
||||
}
|
||||
return service.WithInflightReservation(ctx, res), res.HandlerDone, nil
|
||||
}
|
||||
|
||||
// grokMediaInflightEstimate 媒体生成请求的估算输入;状态/内容查询返回空模型(不预留:
|
||||
// 查询会为已生成的媒体计费,不能因余额预留而拦截用户取回已付费结果)。
|
||||
func grokMediaInflightEstimate(endpoint service.GrokMediaEndpoint, model string, info service.GrokMediaRequestInfo, body []byte) service.InflightEstimateRequest {
|
||||
if !endpoint.IsGenerationRequest() {
|
||||
return service.InflightEstimateRequest{}
|
||||
}
|
||||
switch endpoint {
|
||||
case service.GrokMediaEndpointImagesGenerations, service.GrokMediaEndpointImagesEdits:
|
||||
return service.InflightEstimateRequest{Model: model, BodyBytes: len(body), Kind: service.InflightEstimateImage, Units: info.N}
|
||||
default:
|
||||
return service.InflightEstimateRequest{
|
||||
Model: model,
|
||||
BodyBytes: len(body),
|
||||
Kind: service.InflightEstimateVideo,
|
||||
Units: 1,
|
||||
VideoResolution: info.Resolution,
|
||||
VideoDurationSeconds: info.DurationSeconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// grokVoiceSTTBytesPerSecond STT 时长粗估(~128kbps 压缩音频)。
|
||||
const grokVoiceSTTBytesPerSecond = 16000
|
||||
|
||||
// grokVoiceInflightEstimate 语音 HTTP 接口估算:TTS 按输入字符数(百万字符),STT 按音频字节粗估时长(小时)。
|
||||
// 其他接口(custom-voices)无音频计量,返回的估算为 0(不预留)。
|
||||
func grokVoiceInflightEstimate(endpoint string, body []byte) service.InflightEstimateRequest {
|
||||
req := service.InflightEstimateRequest{Model: endpoint, Kind: service.InflightEstimateAudio, AudioMode: endpoint}
|
||||
switch endpoint {
|
||||
case "tts":
|
||||
req.AudioUnits = float64(len([]rune(extractGrokTTSInputText(body)))) / 1e6
|
||||
case "stt":
|
||||
req.AudioUnits = float64(len(body)) / grokVoiceSTTBytesPerSecond / 3600
|
||||
}
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRequestMaxOutputTokens(t *testing.T) {
|
||||
require.Equal(t, 1024, requestMaxOutputTokens([]byte(`{"max_tokens":1024}`)))
|
||||
require.Equal(t, 2048, requestMaxOutputTokens([]byte(`{"max_completion_tokens":2048}`)))
|
||||
require.Equal(t, 4096, requestMaxOutputTokens([]byte(`{"max_output_tokens":4096}`)))
|
||||
require.Equal(t, 512, requestMaxOutputTokens([]byte(`{"generationConfig":{"maxOutputTokens":512}}`)))
|
||||
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{"max_tokens":"x"}`)))
|
||||
require.Equal(t, 0, requestMaxOutputTokens([]byte(`{}`)))
|
||||
}
|
||||
|
||||
type countingEstimator struct {
|
||||
calls int
|
||||
cost float64
|
||||
priced bool
|
||||
}
|
||||
|
||||
func (e *countingEstimator) EstimateInflightReservation(context.Context, *service.APIKey, service.InflightEstimateRequest) (float64, bool) {
|
||||
e.calls++
|
||||
return e.cost, e.priced
|
||||
}
|
||||
|
||||
func newInflightTestGinContext() *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestReserveInflightBalance_SkipsWhenDisabledOrSubscription(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billing.Stop)
|
||||
est := &countingEstimator{cost: 1, priced: true}
|
||||
apiKey := &service.APIKey{User: &service.User{ID: 1}}
|
||||
|
||||
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("m", []byte(`{}`)))
|
||||
require.NoError(t, err)
|
||||
done()
|
||||
require.Equal(t, 0, est.calls, "disabled switch must not even estimate")
|
||||
|
||||
cfg.Billing.InflightReservation.Enabled = true
|
||||
apiKey.Group = &service.Group{SubscriptionType: service.SubscriptionTypeSubscription}
|
||||
done, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, &service.UserSubscription{}, tokenInflightEstimate("m", []byte(`{}`)))
|
||||
require.NoError(t, err)
|
||||
done()
|
||||
require.Equal(t, 0, est.calls, "subscription mode must be unaffected")
|
||||
}
|
||||
|
||||
func TestReserveInflightBalance_UnpricedFailOpenByDefaultFailClosedOptIn(t *testing.T) {
|
||||
cache := newHandlerInflightCache(10)
|
||||
cfg := &config.Config{}
|
||||
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
||||
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billing.Stop)
|
||||
apiKey := &service.APIKey{User: &service.User{ID: 1}}
|
||||
est := &countingEstimator{priced: false}
|
||||
|
||||
done, err := reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
|
||||
require.NoError(t, err)
|
||||
done()
|
||||
|
||||
cfg.Billing.InflightReservation.FailClosedOnUnpriced = true
|
||||
_, err = reserveInflightBalance(newInflightTestGinContext(), billing, est, apiKey, nil, tokenInflightEstimate("unknown", nil))
|
||||
require.ErrorIs(t, err, service.ErrInsufficientBalance)
|
||||
}
|
||||
|
||||
// handlerInflightCache 内存版余额缓存 + 在途预留(语义同 Redis Lua)。
|
||||
type handlerInflightCache struct {
|
||||
service.BillingCache
|
||||
mu sync.Mutex
|
||||
balance float64
|
||||
res map[string]float64
|
||||
}
|
||||
|
||||
func newHandlerInflightCache(balance float64) *handlerInflightCache {
|
||||
return &handlerInflightCache{balance: balance, res: map[string]float64{}}
|
||||
}
|
||||
|
||||
func (m *handlerInflightCache) GetUserBalance(context.Context, int64) (float64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.balance, nil
|
||||
}
|
||||
|
||||
func (m *handlerInflightCache) GetUserPlatformQuotaCache(context.Context, int64, string) (*service.UserPlatformQuotaCacheEntry, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (m *handlerInflightCache) ReserveInflightBalance(_ context.Context, _ int64, id string, amount, balance float64, _ time.Duration) (bool, float64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
sum := 0.0
|
||||
for _, v := range m.res {
|
||||
sum += v
|
||||
}
|
||||
if len(m.res) > 0 && balance-sum < amount {
|
||||
return false, sum, nil
|
||||
}
|
||||
m.res[id] = amount
|
||||
return true, sum, nil
|
||||
}
|
||||
|
||||
func (m *handlerInflightCache) ReleaseInflightBalance(_ context.Context, _ int64, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.res, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *handlerInflightCache) count() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.res)
|
||||
}
|
||||
|
||||
func TestWrapUsageRecordTaskContext_HandsReservationToBillingTask(t *testing.T) {
|
||||
cache := newHandlerInflightCache(1)
|
||||
cfg := &config.Config{}
|
||||
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
||||
billing := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billing.Stop)
|
||||
apiKey := &service.APIKey{User: &service.User{ID: 5}}
|
||||
|
||||
c := newInflightTestGinContext()
|
||||
done, err := reserveInflightBalance(c, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, cache.count())
|
||||
|
||||
ran := false
|
||||
task, abandon := wrapUsageRecordTaskContext(c.Request.Context(), func(context.Context) { ran = true })
|
||||
done() // handler returns; billing still pending
|
||||
require.Equal(t, 1, cache.count(), "reservation held until the billing task finishes")
|
||||
task(context.Background())
|
||||
require.True(t, ran)
|
||||
require.Equal(t, 0, cache.count())
|
||||
abandon() // idempotent with the task's own done
|
||||
|
||||
// Dropped task: the submitter abandons it and the reservation is released.
|
||||
c2 := newInflightTestGinContext()
|
||||
done2, err := reserveInflightBalance(c2, billing, &countingEstimator{cost: 0.9, priced: true}, apiKey, nil, tokenInflightEstimate("m", nil))
|
||||
require.NoError(t, err)
|
||||
_, abandon2 := wrapUsageRecordTaskContext(c2.Request.Context(), func(context.Context) {})
|
||||
done2()
|
||||
require.Equal(t, 1, cache.count())
|
||||
abandon2()
|
||||
require.Equal(t, 0, cache.count())
|
||||
}
|
||||
|
||||
// 新接入的端点(独立 web_search):在途预留超过余额时拒绝,且不残留预留。
|
||||
func TestWebSearch_RejectsWhenInflightExceedsBalance(t *testing.T) {
|
||||
cache := newHandlerInflightCache(1.5)
|
||||
cfg := &config.Config{}
|
||||
cfg.Billing.InflightReservation = config.InflightReservationConfig{Enabled: true, TTLSeconds: 60}
|
||||
billingCache := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billingCache.Stop)
|
||||
billing := service.NewBillingService(cfg, nil)
|
||||
gw := service.NewGatewayService(
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil, billing, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, service.NewModelPricingResolver(nil, billing), nil, nil, nil,
|
||||
)
|
||||
h := &GatewayHandler{gatewayService: gw, billingCacheService: billingCache}
|
||||
|
||||
groupID := int64(3)
|
||||
perK := 1000.0 // $1 per search
|
||||
apiKey := &service.APIKey{
|
||||
ID: 9, User: &service.User{ID: 42, Balance: 1.5}, GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, RateMultiplier: 1, SearchPricePer1k: &perK},
|
||||
}
|
||||
|
||||
// Another in-flight request of this user already holds $1.
|
||||
held, err := billingCache.ReserveInflight(context.Background(), apiKey.User, apiKey.Group, nil, 1.0)
|
||||
require.NoError(t, err)
|
||||
defer held.HandlerDone()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/web_search", bytes.NewBufferString(`{"query":"sub2api"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
|
||||
|
||||
h.WebSearch(c)
|
||||
|
||||
require.NotEqual(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), "balance")
|
||||
require.Equal(t, 1, cache.count(), "rejected request must not leave a reservation behind")
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag
|
||||
|
||||
func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} {
|
||||
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformTypeSafe, service.PlatformComposite} {
|
||||
for _, mapped := range []bool{false, true} {
|
||||
name := platform + "/fallback"
|
||||
if mapped {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -22,6 +23,16 @@ type gatewayModelsAccountRepoStub struct {
|
||||
byGroup map[int64][]service.Account
|
||||
}
|
||||
|
||||
type gatewayModelsRouteRepoStub struct {
|
||||
service.CompositeModelRouteRepository
|
||||
routes []service.CompositeModelRoute
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *gatewayModelsRouteRepoStub) ListByGroup(_ context.Context, _ int64, _ bool) ([]service.CompositeModelRoute, error) {
|
||||
return s.routes, s.err
|
||||
}
|
||||
|
||||
type gatewayModelsResponseForTest struct {
|
||||
Object string `json:"object"`
|
||||
Data []gatewayModelItemForTest `json:"data"`
|
||||
@@ -81,16 +92,80 @@ func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx conte
|
||||
return s.ListSchedulableByGroupID(ctx, *groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository, routeRepo ...service.CompositeModelRouteRepository) *GatewayHandler {
|
||||
var resolver *service.CompositeRouteResolver
|
||||
if len(routeRepo) > 0 {
|
||||
resolver = service.NewCompositeRouteResolver(routeRepo[0])
|
||||
}
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, resolver, nil, nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCompositeRouteModelsAppearInBothCatalogs(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 7784
|
||||
accounts := &gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"mapped-model": "upstream"},
|
||||
}}},
|
||||
}}
|
||||
routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{
|
||||
{PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
||||
{PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
||||
{PublicModel: "mapped-model", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
||||
{PublicModel: "image-only", MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true},
|
||||
{PublicModel: "prefix-", MatchType: service.CompositeRouteMatchPrefix, Enabled: true},
|
||||
{PublicModel: "disabled", MatchType: service.CompositeRouteMatchExact, Enabled: false},
|
||||
}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
repo service.CompositeModelRouteRepository
|
||||
allowlist []string
|
||||
want []string
|
||||
}{
|
||||
{"routes", routes, nil, []string{"mapped-model", "route-only", "image-only"}},
|
||||
{"allowlist", routes, []string{"route-only", "disabled", "unknown"}, []string{"route-only"}},
|
||||
{"lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, nil, []string{"mapped-model"}},
|
||||
{"allowlisted lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, []string{"mapped-model", "unknown"}, []string{"mapped-model"}},
|
||||
{"no route repo", nil, nil, []string{"mapped-model"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newGatewayModelsHandlerForTest(accounts, tc.repo)
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformComposite}
|
||||
if tc.allowlist != nil {
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: tc.allowlist}
|
||||
}
|
||||
for _, endpoint := range []string{"models", "codex"} {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
if endpoint == "models" {
|
||||
h.Models(c)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, tc.want, modelIDsForTest(got.Data))
|
||||
} else {
|
||||
h.CodexModels(c)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
want := tc.want
|
||||
if tc.name == "routes" {
|
||||
want = []string{"mapped-model", "route-only"}
|
||||
}
|
||||
require.ElementsMatch(t, want, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
||||
require.NotEmpty(t, antigravityIDs)
|
||||
@@ -932,6 +1007,10 @@ func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
func TestDefaultModelIDsForPlatform_TypeSafeUsesJev(t *testing.T) {
|
||||
require.Equal(t, []string{"jev-latest"}, defaultModelIDsForPlatform(service.PlatformTypeSafe))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 130
|
||||
@@ -1499,9 +1578,9 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
|
||||
restricted bool
|
||||
want []string
|
||||
}{
|
||||
{"selected and ordered", []string{"gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6-luna", "gpt-6-sol"}},
|
||||
{"selected and ordered", []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}},
|
||||
{"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}},
|
||||
{"account restricts new models", []string{"gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}},
|
||||
{"account restricts new models", []string{"gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
groupID := int64(25)
|
||||
@@ -1522,3 +1601,232 @@ func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: jev-latest only works through /v1/systemone, so Composite groups list
|
||||
// it in /v1/models only when they can serve it, and never in the Codex manifest.
|
||||
func TestGatewayModels_CompositeTypeSafeListingScope(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(66)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAnthropic}, {ID: 2, Platform: service.PlatformTypeSafe, Type: service.AccountTypeAPIKey}},
|
||||
},
|
||||
})
|
||||
newContext := func(path string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
return c, rec
|
||||
}
|
||||
|
||||
c, rec := newContext("/v1/models")
|
||||
h.Models(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var models gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models))
|
||||
require.Contains(t, modelIDsForTest(models.Data), "jev-latest")
|
||||
require.Contains(t, modelIDsForTest(models.Data), "claude-opus-4-6")
|
||||
|
||||
c, rec = newContext("/models?client_version=0.147.0")
|
||||
h.CodexModels(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var manifest codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest))
|
||||
slugs := codexModelSlugsForTest(manifest.Models)
|
||||
require.Contains(t, slugs, "claude-opus-4-6")
|
||||
require.NotContains(t, slugs, "jev-latest")
|
||||
}
|
||||
|
||||
func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing.T) {
|
||||
require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest")
|
||||
require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest")
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeExactRoutesRespectTypeSafeIsolation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 7784
|
||||
routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{
|
||||
{PublicModel: "systemone-any-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointAny, Enabled: true},
|
||||
{PublicModel: "systemone-responses-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
{PublicModel: "systemone-legacy-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
||||
{PublicModel: "jev-latest", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
||||
{PublicModel: "responses-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
{PublicModel: "image-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true},
|
||||
}}
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{}, routes)
|
||||
wantModels := make([]string, 0, len(routes.routes))
|
||||
for _, route := range routes.routes {
|
||||
wantModels = append(wantModels, route.PublicModel)
|
||||
}
|
||||
for _, allowlisted := range []bool{false, true} {
|
||||
name := "unrestricted"
|
||||
if allowlisted {
|
||||
name = "allowlisted"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformComposite}
|
||||
if allowlisted {
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{
|
||||
"systemone-any-alias", "systemone-responses-alias", "systemone-legacy-alias", "jev-latest", "responses-alias", "image-alias",
|
||||
}}
|
||||
}
|
||||
for _, codex := range []bool{false, true} {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
if codex {
|
||||
h.CodexModels(ctx)
|
||||
var manifest codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest))
|
||||
require.Equal(t, []string{"responses-alias"}, codexModelSlugsForTest(manifest.Models))
|
||||
} else {
|
||||
h.Models(ctx)
|
||||
var models gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models))
|
||||
require.ElementsMatch(t, wantModels, modelIDsForTest(models.Data))
|
||||
}
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeCodexFiltersEffectiveResponsesRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 7793
|
||||
defaultModel := defaultModelIDsForPlatform(service.PlatformOpenAI)[0]
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
model string
|
||||
accounts []service.Account
|
||||
routes []service.CompositeModelRoute
|
||||
}{
|
||||
{
|
||||
name: "colliding exact routes",
|
||||
model: "shared-alias",
|
||||
routes: []service.CompositeModelRoute{
|
||||
{PublicModel: "shared-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointAny, Priority: 1, Enabled: true},
|
||||
{PublicModel: "shared-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Priority: 100, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account alias exact override",
|
||||
model: "account-alias",
|
||||
accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"account-alias": "gpt-5"},
|
||||
}}},
|
||||
routes: []service.CompositeModelRoute{
|
||||
{PublicModel: "account-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account alias prefix override",
|
||||
model: "account-alias",
|
||||
accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"account-alias": "gpt-5"},
|
||||
}}},
|
||||
routes: []service.CompositeModelRoute{
|
||||
{PublicModel: "account-", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchPrefix, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account default override",
|
||||
model: defaultModel,
|
||||
accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI}},
|
||||
routes: []service.CompositeModelRoute{
|
||||
{PublicModel: defaultModel, TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fallback default override",
|
||||
model: defaultModel,
|
||||
routes: []service.CompositeModelRoute{
|
||||
{PublicModel: defaultModel, TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchPrefix, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
for _, allowlisted := range []bool{false, true} {
|
||||
name := "unrestricted"
|
||||
if allowlisted {
|
||||
name = "allowlisted"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
routes := append([]service.CompositeModelRoute(nil), scenario.routes...)
|
||||
if scenario.name != "fallback default override" {
|
||||
routes = append(routes, service.CompositeModelRoute{
|
||||
PublicModel: "responses-control", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true,
|
||||
})
|
||||
}
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{groupID: scenario.accounts},
|
||||
}, &gatewayModelsRouteRepoStub{routes: routes})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformComposite}
|
||||
if allowlisted {
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{scenario.model, "responses-control"}}
|
||||
}
|
||||
for _, codex := range []bool{false, true} {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
if codex {
|
||||
h.CodexModels(ctx)
|
||||
var manifest codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &manifest))
|
||||
slugs := codexModelSlugsForTest(manifest.Models)
|
||||
require.NotContains(t, slugs, scenario.model)
|
||||
if scenario.name != "fallback default override" {
|
||||
require.Contains(t, slugs, "responses-control")
|
||||
} else if allowlisted {
|
||||
require.Empty(t, slugs)
|
||||
}
|
||||
} else {
|
||||
h.Models(ctx)
|
||||
var models gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &models))
|
||||
require.Contains(t, modelIDsForTest(models.Data), scenario.model)
|
||||
}
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeCodexRouteLookupErrorKeepsFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 7793
|
||||
model := defaultCodexModelIDsForPlatform(service.PlatformComposite)[0]
|
||||
for _, allowlisted := range []bool{false, true} {
|
||||
name := "unrestricted"
|
||||
if allowlisted {
|
||||
name = "allowlisted"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{}, &gatewayModelsRouteRepoStub{err: errors.New("unavailable")})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformComposite}
|
||||
if allowlisted {
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{model}}
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(ctx)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var manifest codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &manifest))
|
||||
require.Contains(t, codexModelSlugsForTest(manifest.Models), model)
|
||||
if allowlisted {
|
||||
require.Len(t, manifest.Models, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/typesafe"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const systemOneOnlyPlatformMessage = "TypeSafe models are only available through POST /v1/systemone"
|
||||
|
||||
// rejectSystemOneOnlyPlatform stops TypeSafe traffic from entering a non System
|
||||
// One protocol chain. TypeSafe accounts only speak the native System One
|
||||
// protocol; letting them reach the Anthropic/OpenAI converters would send
|
||||
// foreign payloads (and the account key) to the wrong upstream path and feed
|
||||
// the resulting auth failures back into account state.
|
||||
func rejectSystemOneOnlyPlatform(c *gin.Context, apiKey *service.APIKey, writeError func(*gin.Context, int, string, string)) bool {
|
||||
if _, forced := middleware2.GetForcePlatformFromContext(c); forced {
|
||||
return false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformTypeSafe {
|
||||
return false
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
writeError(c, http.StatusNotFound, "not_found_error", systemOneOnlyPlatformMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
// SystemOne proxies TypeSafe's native, non-streaming System One protocol.
|
||||
func (h *GatewayHandler) SystemOne(c *gin.Context) {
|
||||
requestStart := time.Now()
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil || apiKey.Group == nil {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
reqLog := requestLogger(c, "handler.gateway.systemone",
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
|
||||
body, err := readLenientJSONRequestBodyWithPrealloc(c.Request, h.cfg)
|
||||
if err != nil {
|
||||
if maxErr, ok := extractMaxBytesError(err); ok {
|
||||
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||
return
|
||||
}
|
||||
model, err := typesafe.ValidateSystemOneRequest(body)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
ensureCompositeTargetPlatform(c, apiKey, model)
|
||||
if apiKey.Group.Platform != service.PlatformTypeSafe &&
|
||||
(apiKey.Group.Platform != service.PlatformComposite || !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformTypeSafe)) {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "System One is only available for TypeSafe and compatible Composite groups")
|
||||
return
|
||||
}
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolTypeSafeSystemOne, model, body); decision != nil && !decision.AllowNextStage {
|
||||
h.anthropicSecurityAuditError(c, decision)
|
||||
return
|
||||
}
|
||||
|
||||
setOpsRequestContext(c, model, false)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, model)
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
|
||||
streamStarted := false
|
||||
userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, false, &streamStarted)
|
||||
if err != nil {
|
||||
reqLog.Warn("systemone.user_slot_acquire_failed", zap.Error(err))
|
||||
h.handleConcurrencyError(c, err, "user", false)
|
||||
return
|
||||
}
|
||||
// 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏。
|
||||
userRelease = wrapReleaseOnDone(c.Request.Context(), userRelease)
|
||||
if userRelease != nil {
|
||||
defer userRelease()
|
||||
}
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("systemone.billing_eligibility_check_failed", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(model, body))
|
||||
if err != nil {
|
||||
reqLog.Info("systemone.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
fs := NewFailoverState(h.maxAccountSwitches, false)
|
||||
for {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", model, fs.FailedAccountIDs, "", subject.UserID)
|
||||
if err == nil && (selection == nil || selection.Account == nil) {
|
||||
err = service.ErrNoAvailableAccounts
|
||||
}
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("systemone.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, model, model, service.PlatformTypeSafe)
|
||||
cls = classifySelectionFailureError(err, cls)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
reqLog.Warn("systemone.select_account_no_available", zap.Bool("model_not_found", cls.ModelNotFound), zap.Error(err))
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
switch fs.HandleSelectionExhausted(c.Request.Context()) {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
default:
|
||||
if fs.LastFailoverErr != nil {
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
|
||||
} else {
|
||||
h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
account := selection.Account
|
||||
|
||||
accountRelease := selection.ReleaseFunc
|
||||
if !selection.Acquired {
|
||||
if selection.WaitPlan == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
return
|
||||
}
|
||||
accountRelease, err = h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted)
|
||||
if err != nil {
|
||||
reqLog.Warn("systemone.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
h.handleConcurrencyError(c, err, "account", false)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 准入终检:与其他网关入口一致,利润控制否决的账号不得承接本次请求。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountRelease != nil {
|
||||
accountRelease()
|
||||
}
|
||||
reqLog.Debug("systemone.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("systemone.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
accountRelease = wrapReleaseOnDone(c.Request.Context(), accountRelease)
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
service.SetOpsUpstreamModel(c, model)
|
||||
|
||||
forwardStart := time.Now()
|
||||
result, forwardErr := h.gatewayService.ForwardSystemOne(c.Request.Context(), c, account, body)
|
||||
if accountRelease != nil {
|
||||
accountRelease()
|
||||
}
|
||||
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
|
||||
|
||||
if forwardErr != nil {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
if errors.As(forwardErr, &failoverErr) {
|
||||
switch fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) {
|
||||
case FailoverContinue:
|
||||
reqLog.Warn("systemone.upstream_failover_switching",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("switch_count", fs.SwitchCount),
|
||||
)
|
||||
continue
|
||||
case FailoverExhausted:
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformTypeSafe, false)
|
||||
return
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
var upstreamErr *service.SystemOneUpstreamError
|
||||
if errors.As(forwardErr, &upstreamErr) {
|
||||
status := upstreamErr.StatusCode
|
||||
if !service.IsSystemOneRequestErrorStatus(status) {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
h.errorResponse(c, status, "upstream_error", "TypeSafe rejected the request")
|
||||
return
|
||||
}
|
||||
reqLog.Warn("systemone.forward_failed", zap.Int64("account_id", account.ID), zap.Error(forwardErr))
|
||||
if errors.Is(forwardErr, typesafe.ErrSystemOneResponseTooLarge) {
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe response exceeds the gateway size limit")
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "TypeSafe upstream request failed")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(result.StatusCode, result.ContentType, result.Body)
|
||||
h.recordSystemOneUsage(c, apiKey, account, subscription, channelMapping, model, body, result, subject.UserID, pricingAt)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) recordSystemOneUsage(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, mapping service.ChannelMappingResult, model string, body []byte, result *service.SystemOneForwardResult, userID int64, pricingAt time.Time) {
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
|
||||
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: &result.ForwardResult,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
SessionID: sessionID,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, mapping, model, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.systemone"),
|
||||
zap.Int64("user_id", userID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("systemone.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const validSystemOneHandlerBody = `{"model":"jev-latest","state":"sample","questions":{"q":{"type":"noul","instructions":"Evaluate"}}}`
|
||||
|
||||
func newSystemOneHandlerContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/systemone", strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
func TestSystemOneRequiresAuthentication(t *testing.T) {
|
||||
c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody)
|
||||
(&GatewayHandler{}).SystemOne(c)
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "authentication_error")
|
||||
}
|
||||
|
||||
func TestSystemOneRejectsNonTypeSafeGroupBeforeScheduling(t *testing.T) {
|
||||
c, recorder := newSystemOneHandlerContext(validSystemOneHandlerBody)
|
||||
groupID := int64(3)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1})
|
||||
|
||||
(&GatewayHandler{cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}}).SystemOne(c)
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "only available for TypeSafe")
|
||||
}
|
||||
|
||||
func newTypeSafeGroupContext(t *testing.T, path, body, groupPlatform string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
c, recorder := newSystemOneHandlerContext(body)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
groupID := int64(9)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 4, UserID: 5, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: groupPlatform}})
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 5, Concurrency: 1})
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
func TestRejectSystemOneOnlyPlatform(t *testing.T) {
|
||||
write := func(c *gin.Context, status int, errType, message string) {
|
||||
c.JSON(status, gin.H{"type": errType, "message": message})
|
||||
}
|
||||
|
||||
c, recorder := newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformTypeSafe)
|
||||
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
|
||||
|
||||
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformComposite)
|
||||
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
|
||||
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformTypeSafe))
|
||||
require.True(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
|
||||
|
||||
c, _ = newTypeSafeGroupContext(t, "/v1/messages", `{}`, service.PlatformAnthropic)
|
||||
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
|
||||
|
||||
c, _ = newTypeSafeGroupContext(t, "/antigravity/v1/messages", `{}`, service.PlatformTypeSafe)
|
||||
c.Set(string(middleware2.ContextKeyForcePlatform), service.PlatformAntigravity)
|
||||
require.False(t, rejectSystemOneOnlyPlatform(c, mustAPIKey(t, c), write))
|
||||
}
|
||||
|
||||
func mustAPIKey(t *testing.T, c *gin.Context) *service.APIKey {
|
||||
t.Helper()
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
require.True(t, ok)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
func TestTypeSafeGroupsRejectNonSystemOneProtocolsBeforeScheduling(t *testing.T) {
|
||||
h := &GatewayHandler{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
|
||||
gatewayService: &service.GatewayService{},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
handler func(*gin.Context)
|
||||
}{
|
||||
{"messages", "/v1/messages", `{"model":"jev-latest","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}`, h.Messages},
|
||||
{"count_tokens", "/v1/messages/count_tokens", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.CountTokens},
|
||||
{"chat_completions", "/v1/chat/completions", `{"model":"jev-latest","messages":[{"role":"user","content":"hi"}]}`, h.ChatCompletions},
|
||||
{"responses", "/v1/responses", `{"model":"jev-latest","input":"hi"}`, h.Responses},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, recorder := newTypeSafeGroupContext(t, tc.path, tc.body, service.PlatformTypeSafe)
|
||||
tc.handler(c)
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
|
||||
require.Contains(t, recorder.Body.String(), systemOneOnlyPlatformMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,18 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"), Kind: service.InflightEstimatePerRequest, SearchCalls: 1})
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
reqLog := requestLogger(c, "handler.gateway.web_search")
|
||||
// Audit user search query before upstream Grok web_search traffic.
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
cachedBillingInputPrice = 0.00000125
|
||||
cachedBillingStoragePrice = 0.0000045
|
||||
cachedBillingAliasStoragePrice = 0.000009
|
||||
cachedBillingTokens = 1_000_000
|
||||
)
|
||||
|
||||
type cachedBillingRepo struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*service.GeminiCachedContent
|
||||
// mutations 按记录 id 保存修改意图。
|
||||
mutations map[int64]cachedBillingMutation
|
||||
failCommit bool
|
||||
nextID int64
|
||||
deleted []int64
|
||||
}
|
||||
|
||||
type cachedBillingMutation struct {
|
||||
token string
|
||||
leaseUntil time.Time
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) Create(_ context.Context, record *service.GeminiCachedContent) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.nextID++
|
||||
record.ID = r.nextID
|
||||
cp := *record
|
||||
r.records[record.PublicID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.records[publicID]
|
||||
if !ok || record.APIKeyID != apiKeyID || r.liveLocked(record.ID) == nil {
|
||||
return nil, service.ErrGeminiCachedContentNotFound
|
||||
}
|
||||
cp := *record
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) ListForOwner(context.Context, int64, int64, time.Time, int64, int) ([]*service.GeminiCachedContent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// liveLocked 返回未删除的记录;调用方须持有 mu。
|
||||
func (r *cachedBillingRepo) liveLocked(id int64) *service.GeminiCachedContent {
|
||||
for _, deleted := range r.deleted {
|
||||
if deleted == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for _, record := range r.records {
|
||||
if record.ID == id {
|
||||
return record
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) BeginMutation(_ context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record := r.liveLocked(id)
|
||||
if record == nil {
|
||||
return false, service.ErrGeminiCachedContentNotFound
|
||||
}
|
||||
if _, pending := r.mutations[id]; pending || !record.ExpireTime.Equal(baseline) {
|
||||
return false, nil
|
||||
}
|
||||
r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) TakeOverStaleMutation(_ context.Context, id int64, token string, lease time.Duration) (*service.GeminiCachedContent, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record := r.liveLocked(id)
|
||||
mutation, pending := r.mutations[id]
|
||||
if record == nil || !pending || mutation.leaseUntil.After(time.Now()) {
|
||||
return nil, nil
|
||||
}
|
||||
r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)}
|
||||
cp := *record
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) CommitMutation(_ context.Context, id int64, token string, expireTime time.Time) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failCommit {
|
||||
return errors.New("db unavailable")
|
||||
}
|
||||
record := r.liveLocked(id)
|
||||
mutation, pending := r.mutations[id]
|
||||
if record == nil || !pending || mutation.token != token {
|
||||
return service.ErrGeminiCachedContentMutationConflict
|
||||
}
|
||||
record.ExpireTime = expireTime
|
||||
delete(r.mutations, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) ReleaseMutation(_ context.Context, id int64, token string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if mutation, pending := r.mutations[id]; pending && mutation.token == token {
|
||||
delete(r.mutations, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) ListStaleMutations(_ context.Context, limit int) ([]int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var ids []int64
|
||||
for id, mutation := range r.mutations {
|
||||
if len(ids) < limit && mutation.leaseUntil.Before(time.Now()) && r.liveLocked(id) != nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingRepo) SoftDelete(_ context.Context, id int64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.deleted = append(r.deleted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingMutation 返回记录是否有未清除的修改意图。
|
||||
func (r *cachedBillingRepo) pendingMutation(id int64) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
_, pending := r.mutations[id]
|
||||
return pending
|
||||
}
|
||||
|
||||
// expireLease 让记录上的修改意图租约立即过期。
|
||||
func (r *cachedBillingRepo) expireLease(id int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
mutation := r.mutations[id]
|
||||
mutation.leaseUntil = time.Now().Add(-time.Second)
|
||||
r.mutations[id] = mutation
|
||||
}
|
||||
|
||||
type cachedBillingAccountRepo struct {
|
||||
service.AccountRepository
|
||||
accounts map[int64]*service.Account
|
||||
}
|
||||
|
||||
func (r *cachedBillingAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
|
||||
account, ok := r.accounts[id]
|
||||
if !ok {
|
||||
return nil, service.ErrAccountNotFound
|
||||
}
|
||||
cp := *account
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
type cachedBillingUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
createBody string
|
||||
calls []string
|
||||
// patchTTLs 按顺序记录 PATCH 请求体中的 ttl。
|
||||
patchTTLs []string
|
||||
beforePost func(n int)
|
||||
// failures 按请求方法返回上游错误响应;transportErrors 按请求方法返回传输层错误(上游结果未知)。
|
||||
failures map[string]cachedBillingUpstreamFailure
|
||||
transportErrors map[string]error
|
||||
}
|
||||
|
||||
type cachedBillingUpstreamFailure struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (u *cachedBillingUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
var patch struct {
|
||||
TTL string `json:"ttl"`
|
||||
}
|
||||
if req.Method == http.MethodPatch {
|
||||
_ = json.NewDecoder(req.Body).Decode(&patch)
|
||||
}
|
||||
u.mu.Lock()
|
||||
u.calls = append(u.calls, req.Method+" "+req.URL.Path)
|
||||
n := len(u.calls)
|
||||
if req.Method == http.MethodPatch {
|
||||
u.patchTTLs = append(u.patchTTLs, patch.TTL)
|
||||
}
|
||||
failure, failed := u.failures[req.Method]
|
||||
transportErr := u.transportErrors[req.Method]
|
||||
u.mu.Unlock()
|
||||
if transportErr != nil {
|
||||
return nil, transportErr
|
||||
}
|
||||
if failed {
|
||||
return &http.Response{StatusCode: failure.status, Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(failure.body))}, nil
|
||||
}
|
||||
body := "{}"
|
||||
switch req.Method {
|
||||
case http.MethodPost:
|
||||
body = u.createBody
|
||||
case http.MethodPatch:
|
||||
if u.beforePost != nil {
|
||||
u.beforePost(n)
|
||||
}
|
||||
seconds, _ := strconv.ParseInt(strings.TrimSuffix(patch.TTL, "s"), 10, 64)
|
||||
body = fmt.Sprintf(`{"name":"cachedContents/up1","expireTime":%q}`, time.Now().Add(time.Duration(seconds)*time.Second).UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
|
||||
func (u *cachedBillingUpstream) methodCalls(method string) int {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
n := 0
|
||||
for _, call := range u.calls {
|
||||
if strings.HasPrefix(call, method+" ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type cachedBillingUserRepo struct {
|
||||
service.UserRepository
|
||||
balance float64
|
||||
}
|
||||
|
||||
func (r *cachedBillingUserRepo) GetByID(_ context.Context, id int64) (*service.User, error) {
|
||||
return &service.User{ID: id, Balance: r.balance}, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingUserRepo) DeductBalance(context.Context, int64, float64) error { return nil }
|
||||
|
||||
type cachedBillingUsageRepo struct {
|
||||
service.UsageLogRepository
|
||||
mu sync.Mutex
|
||||
logs []*service.UsageLog
|
||||
}
|
||||
|
||||
func (r *cachedBillingUsageRepo) Create(_ context.Context, log *service.UsageLog) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.logs = append(r.logs, log)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *cachedBillingUsageRepo) first() *service.UsageLog {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.logs[0]
|
||||
}
|
||||
|
||||
func (r *cachedBillingUsageRepo) totals() []float64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]float64, 0, len(r.logs))
|
||||
for _, log := range r.logs {
|
||||
out = append(out, log.TotalCost)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type cachedBillingEnv struct {
|
||||
h *GatewayHandler
|
||||
repo *cachedBillingRepo
|
||||
upstream *cachedBillingUpstream
|
||||
usage *cachedBillingUsageRepo
|
||||
apiKey *service.APIKey
|
||||
group *service.Group
|
||||
// accounts 是调度快照桩直接返回的账号对象,测试可改其凭据。
|
||||
accounts map[int64]*service.Account
|
||||
}
|
||||
|
||||
func newCachedBillingEnv(t *testing.T, balance float64, accountIDs ...int64) *cachedBillingEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "model_pricing.json"), []byte(fmt.Sprintf(`{"gemini-2.5-pro":{
|
||||
"litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001,
|
||||
"cache_storage_cost_per_token_per_hour":%g},"gemini-alias":{
|
||||
"litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001,
|
||||
"cache_storage_cost_per_token_per_hour":%g}}`,
|
||||
cachedBillingInputPrice, cachedBillingStoragePrice, cachedBillingInputPrice, cachedBillingAliasStoragePrice)), 0o644))
|
||||
cfg := &config.Config{}
|
||||
cfg.Pricing.DataDir = dir
|
||||
cfg.Pricing.UpdateIntervalHours = 100000
|
||||
cfg.Default.RateMultiplier = 1
|
||||
pricing := service.NewPricingService(cfg, nil)
|
||||
require.NoError(t, pricing.Initialize())
|
||||
billing := service.NewBillingService(cfg, pricing)
|
||||
|
||||
groupID := int64(42)
|
||||
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive, RateMultiplier: 1}
|
||||
accounts := map[int64]*service.Account{}
|
||||
var scheduled []*service.Account
|
||||
for _, id := range accountIDs {
|
||||
account := &service.Account{ID: id, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "k"}, Status: service.StatusActive, Schedulable: true,
|
||||
Concurrency: 5, Priority: 1, AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}}}
|
||||
accounts[id] = account
|
||||
scheduled = append(scheduled, account)
|
||||
}
|
||||
userRepo := &cachedBillingUserRepo{balance: balance}
|
||||
usage := &cachedBillingUsageRepo{}
|
||||
bcs := service.NewBillingCacheService(nil, userRepo, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(bcs.Stop)
|
||||
snapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: scheduled}, nil, nil, nil, nil)
|
||||
gw := service.NewGatewayService(nil, &fakeGroupRepo{group: group}, usage, nil, userRepo, nil, nil, nil, cfg, snapshot, nil,
|
||||
billing, nil, &service.BillingCacheService{}, nil, nil, &service.DeferredService{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
upstream := &cachedBillingUpstream{}
|
||||
compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: accounts}, nil, nil, nil, nil, nil, upstream, nil, cfg)
|
||||
repo := &cachedBillingRepo{records: map[string]*service.GeminiCachedContent{}, mutations: map[int64]cachedBillingMutation{}}
|
||||
h := &GatewayHandler{
|
||||
gatewayService: gw,
|
||||
billingCacheService: bcs,
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatNone, 0),
|
||||
geminiCachedContentService: service.NewGeminiCachedContentService(repo, compat, cfg),
|
||||
}
|
||||
apiKey := &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: group, Status: service.StatusActive,
|
||||
User: &service.User{ID: 3, Concurrency: 10, Balance: balance}}
|
||||
return &cachedBillingEnv{h: h, repo: repo, upstream: upstream, usage: usage, apiKey: apiKey, group: group, accounts: accounts}
|
||||
}
|
||||
|
||||
// TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot 候选过滤直接按快照里的凭据判定:
|
||||
// 第三方地址且未声明上游显式缓存的 Key 账号在选号前就被排除,官方地址与声明了上游显式缓存的账号保留。
|
||||
func TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 500, 900, 901, 902)
|
||||
env.accounts[901].Credentials["base_url"] = "https://third.example.com"
|
||||
env.accounts[902].Credentials["base_url"] = "https://gw.example.com"
|
||||
env.accounts[902].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
|
||||
|
||||
groupID := env.group.ID
|
||||
excluded, err := env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[int64]struct{}{901: {}}, excluded)
|
||||
|
||||
excluded, err = env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 902)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[int64]struct{}{900: {}, 901: {}}, excluded, "绑定缓存的请求只保留持有账号")
|
||||
}
|
||||
|
||||
func (e *cachedBillingEnv) serve(method, target, body string, params gin.Params, call func(*gin.Context)) *httptest.ResponseRecorder {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
c.Request = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, e.group))
|
||||
c.Params = params
|
||||
c.Set(string(middleware.ContextKeyAPIKey), e.apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: e.apiKey.UserID, Concurrency: 10})
|
||||
call(c)
|
||||
return rec
|
||||
}
|
||||
|
||||
// waitForUsage 等待异步计费任务落库(无 worker 池时同步执行,直接返回)。
|
||||
func (e *cachedBillingEnv) waitForUsage(t *testing.T, n int) []float64 {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool { return len(e.usage.totals()) >= n }, time.Second, 10*time.Millisecond)
|
||||
return e.usage.totals()
|
||||
}
|
||||
|
||||
func (e *cachedBillingEnv) create() *httptest.ResponseRecorder {
|
||||
return e.serve(http.MethodPost, "/v1beta/cachedContents",
|
||||
`{"model":"models/gemini-2.5-pro","ttl":"86400s","contents":[{"role":"user","parts":[{"text":"doc"}]}]}`,
|
||||
nil, e.h.GeminiCachedContentsCreate)
|
||||
}
|
||||
|
||||
func (e *cachedBillingEnv) seed(expire time.Time) {
|
||||
e.repo.records["abc"] = &service.GeminiCachedContent{
|
||||
ID: 100, PublicID: "abc", UserID: 3, APIKeyID: 7, GroupID: 42, AccountID: 900,
|
||||
UpstreamName: "cachedContents/up1", Model: "gemini-2.5-pro", UpstreamModel: "models/gemini-2.5-pro",
|
||||
TotalTokenCount: cachedBillingTokens, ExpireTime: expire,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *cachedBillingEnv) patch() *httptest.ResponseRecorder {
|
||||
return e.serve(http.MethodPatch, "/v1beta/cachedContents/abc?updateMask=ttl", `{"ttl":"86400s"}`,
|
||||
gin.Params{{Key: "cacheID", Value: "abc"}}, e.h.GeminiCachedContentsPatch)
|
||||
}
|
||||
|
||||
// TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate 延长有效期沿用创建时的渠道计费口径:
|
||||
// 计费模型来源为请求模型时按别名定价,用量记录带渠道 ID。
|
||||
func TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 500, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.repo.records["abc"].ChannelUsage = service.ChannelUsageFields{
|
||||
ChannelID: 5, OriginalModel: "gemini-alias", ChannelMappedModel: "gemini-2.5-pro",
|
||||
BillingModelSource: service.BillingModelSourceRequested,
|
||||
}
|
||||
|
||||
rec := env.patch()
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
totals := env.waitForUsage(t, 1)
|
||||
require.Len(t, totals, 1)
|
||||
require.InDelta(t, cachedBillingTokens*cachedBillingAliasStoragePrice*23, totals[0], 0.01, "按创建时的计费模型(请求模型别名)计存储费")
|
||||
usageLog := env.usage.first()
|
||||
require.NotNil(t, usageLog.ChannelID)
|
||||
require.Equal(t, int64(5), *usageLog.ChannelID)
|
||||
require.True(t, strings.HasPrefix(usageLog.RequestID, "gcache:patch:abc:"), usageLog.RequestID)
|
||||
}
|
||||
|
||||
func cachedBillingCreateBody(expire time.Time) string {
|
||||
return fmt.Sprintf(`{"name":"cachedContents/up1","usageMetadata":{"totalTokenCount":%d},"expireTime":%q}`,
|
||||
cachedBillingTokens, expire.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsCreate_ChargesInputAndStorage(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 500, 900)
|
||||
env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(100 * time.Hour))
|
||||
|
||||
rec := env.create()
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
totals := env.waitForUsage(t, 1)
|
||||
require.Len(t, totals, 1)
|
||||
require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice+cachedBillingTokens*cachedBillingStoragePrice*24, totals[0], 1e-9)
|
||||
require.Len(t, env.repo.records, 1)
|
||||
for _, record := range env.repo.records {
|
||||
require.False(t, record.ExpireTime.After(time.Now().Add(24*time.Hour)), "本地到期时间不超过请求的有效期")
|
||||
require.Equal(t, "gcache:create:"+record.PublicID, env.usage.first().RequestID, "费用行按缓存 ID 可检索")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsCreate_UnaffordableStorageDeletesUpstreamAndBillsInputOnly(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1, 900)
|
||||
env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(24 * time.Hour))
|
||||
|
||||
rec := env.create()
|
||||
require.NotEqual(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "error")
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存,避免产生存储费")
|
||||
require.Empty(t, env.repo.records, "不登记缓存")
|
||||
totals := env.waitForUsage(t, 1)
|
||||
require.Len(t, totals, 1)
|
||||
require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice, totals[0], 1e-9, "只收已发生的缓存 token 输入费")
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsCreate_UnusableUpstreamResponseCleansUpWithoutRetry(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 500, 900, 901)
|
||||
env.upstream.createBody = `{"name":"cachedContents/up1","expireTime":"2099-01-01T00:00:00Z"}`
|
||||
|
||||
rec := env.create()
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPost), "不换号重建")
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存")
|
||||
require.Empty(t, env.repo.records)
|
||||
require.Empty(t, env.usage.totals())
|
||||
}
|
||||
|
||||
func (e *cachedBillingEnv) patchTTL(id, ttl string) *httptest.ResponseRecorder {
|
||||
return e.serve(http.MethodPatch, "/v1beta/cachedContents/"+id+"?updateMask=ttl", fmt.Sprintf(`{"ttl":%q}`, ttl),
|
||||
gin.Params{{Key: "cacheID", Value: id}}, e.h.GeminiCachedContentsPatch)
|
||||
}
|
||||
|
||||
// requireTTLNear 断言发往上游的 ttl("Ns")与 want 相差不超过 5 秒。
|
||||
func requireTTLNear(t *testing.T, ttl string, want time.Duration) {
|
||||
t.Helper()
|
||||
seconds, err := strconv.ParseInt(strings.TrimSuffix(ttl, "s"), 10, 64)
|
||||
require.NoError(t, err, ttl)
|
||||
require.InDelta(t, want.Seconds(), float64(seconds), 5, ttl)
|
||||
}
|
||||
|
||||
// 同一缓存的修改串行执行:第一个 PATCH 的上游调用返回前,第二个 PATCH 直接得到 409 且不调用上游,
|
||||
// 本地到期时间与上游最后生效的修改一致,只按第一个 PATCH 实际推进的时长收费。
|
||||
func TestGeminiCachedContentsPatch_ConcurrentUpdateConflictsAndKeepsUpstreamInSync(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.upstream.beforePost = func(n int) {
|
||||
if n == 1 {
|
||||
rec := env.patchTTL("abc", "86400s")
|
||||
require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String())
|
||||
require.Contains(t, rec.Body.String(), `"ABORTED"`)
|
||||
}
|
||||
}
|
||||
|
||||
rec := env.patchTTL("abc", "7200s")
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "冲突的修改不调用上游")
|
||||
require.WithinDuration(t, time.Now().Add(2*time.Hour), env.repo.records["abc"].ExpireTime, 5*time.Second, "本地到期时间与上游一致")
|
||||
require.False(t, env.repo.pendingMutation(100))
|
||||
totals := env.waitForUsage(t, 1)
|
||||
require.Len(t, totals, 1)
|
||||
require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice, totals[0], cachedBillingTokens*cachedBillingStoragePrice*time.Minute.Hours(), "只收推进的 1 小时")
|
||||
}
|
||||
|
||||
// 上游已修改但本地写回失败:不计费并保留修改意图;租约内的修改返回 409,
|
||||
// 租约过期后后台恢复把上游有效期回滚到修改前的到期时间并清除意图。
|
||||
func TestGeminiCachedContentsPatch_CommitFailureRollsBackUpstreamWithoutCharge(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
baseline := time.Now().Add(time.Hour)
|
||||
env.seed(baseline)
|
||||
env.repo.failCommit = true
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
|
||||
require.True(t, env.repo.pendingMutation(100), "上游可能已修改:保留修改意图")
|
||||
require.Empty(t, env.usage.totals(), "未提交的修改不收费")
|
||||
|
||||
env.repo.failCommit = false
|
||||
require.Equal(t, http.StatusConflict, env.patch().Code, "租约内的修改被拒绝")
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
|
||||
|
||||
env.repo.expireLease(100)
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch))
|
||||
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
|
||||
require.False(t, env.repo.pendingMutation(100))
|
||||
require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline), "本地到期时间保持修改前的值")
|
||||
require.Empty(t, env.usage.totals())
|
||||
}
|
||||
|
||||
// 租约过期的遗留修改意图在下一次修改前先回滚,随后的修改按修改前的到期时间计费。
|
||||
func TestGeminiCachedContentsPatch_StaleMutationRolledBackBeforeNextUpdate(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
baseline := time.Now().Add(time.Hour)
|
||||
env.seed(baseline)
|
||||
env.repo.failCommit = true
|
||||
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
|
||||
env.repo.failCommit = false
|
||||
env.repo.expireLease(100)
|
||||
|
||||
rec := env.patch()
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.Equal(t, 3, env.upstream.methodCalls(http.MethodPatch), "失败的修改、回滚、本次修改")
|
||||
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
|
||||
require.Equal(t, "86400s", env.upstream.patchTTLs[2])
|
||||
totals := env.waitForUsage(t, 1)
|
||||
require.Len(t, totals, 1)
|
||||
require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice*23, totals[0], 0.01)
|
||||
}
|
||||
|
||||
// 上游结果未知(传输层错误)时保留修改意图等待回滚;上游明确拒绝时清除修改意图。两种情况都不收费。
|
||||
func TestGeminiCachedContentsPatch_MutationKeptOnlyWhenUpstreamOutcomeUnknown(t *testing.T) {
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.upstream.transportErrors = map[string]error{http.MethodPatch: errors.New("connection reset")}
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, env.patch().Code)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
require.Empty(t, env.usage.totals())
|
||||
})
|
||||
t.Run("upstream rejected", func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: {status: http.StatusBadRequest,
|
||||
body: `{"error":{"code":400,"message":"invalid ttl","status":"INVALID_ARGUMENT"}}`}}
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, env.patch().Code)
|
||||
require.False(t, env.repo.pendingMutation(100))
|
||||
require.Empty(t, env.usage.totals())
|
||||
})
|
||||
}
|
||||
|
||||
// 修改前的到期时间已过:恢复时删除上游缓存与本地记录。
|
||||
func TestGeminiCachedContentMutationRecovery_DeletesUpstreamWhenBaselineExpired(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(-time.Minute))
|
||||
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
|
||||
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete))
|
||||
require.Zero(t, env.upstream.methodCalls(http.MethodPatch))
|
||||
require.Equal(t, []int64{100}, env.repo.deleted)
|
||||
}
|
||||
|
||||
// cachedBillingReservationCache 是启用在途预留时的计费缓存桩:余额与订阅用量取固定值,各维度预留按 key 记账。
|
||||
type cachedBillingReservationCache struct {
|
||||
service.BillingCache
|
||||
mu sync.Mutex
|
||||
balance float64
|
||||
sub *service.SubscriptionCacheData
|
||||
reserved map[string]map[string]float64
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) GetUserBalance(context.Context, int64) (float64, error) {
|
||||
return c.balance, nil
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) GetSubscriptionCache(context.Context, int64, int64) (*service.SubscriptionCacheData, error) {
|
||||
return c.sub, nil
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) reserveLocked(key, requestID string, amount float64) float64 {
|
||||
if c.reserved == nil {
|
||||
c.reserved = map[string]map[string]float64{}
|
||||
}
|
||||
if c.reserved[key] == nil {
|
||||
c.reserved[key] = map[string]float64{}
|
||||
}
|
||||
others := 0.0
|
||||
for _, v := range c.reserved[key] {
|
||||
others += v
|
||||
}
|
||||
c.reserved[key][requestID] = amount
|
||||
return others
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) ReserveInflightBalance(_ context.Context, userID int64, requestID string, amount, balance float64, _ time.Duration) (bool, float64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
key := fmt.Sprintf("user:%d", userID)
|
||||
others := c.reserveLocked(key, requestID, amount)
|
||||
if len(c.reserved[key]) > 1 && balance-others < amount {
|
||||
delete(c.reserved[key], requestID)
|
||||
return false, others, nil
|
||||
}
|
||||
return true, others, nil
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) ReleaseInflightBalance(_ context.Context, userID int64, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.reserved[fmt.Sprintf("user:%d", userID)], requestID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) ReserveInflightScope(_ context.Context, scope, requestID string, amount float64, _ time.Duration) (float64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.reserveLocked(scope, requestID, amount), nil
|
||||
}
|
||||
|
||||
func (c *cachedBillingReservationCache) ReleaseInflightScope(_ context.Context, scope, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.reserved[scope], requestID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func enableCachedBillingReservations(t *testing.T, env *cachedBillingEnv, cache *cachedBillingReservationCache) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{}
|
||||
cfg.Billing.InflightReservation.Enabled = true
|
||||
bcs := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(bcs.Stop)
|
||||
env.h.billingCacheService = bcs
|
||||
}
|
||||
|
||||
// 确定金额的续期费按 API Key 与余额维度预留:第一笔续期(约 $103.5)尚未入账时,
|
||||
// 第二个缓存的续期超出 Key 剩余额度或余额,在调用上游前被拒绝。
|
||||
func TestGeminiCachedContentsPatch_UnsettledChargeHoldsKeyQuotaAndBalance(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
balance, quota float64
|
||||
}{
|
||||
{name: "api key quota", balance: 1000, quota: 110},
|
||||
{name: "balance", balance: 110},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, tc.balance, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
second := *env.repo.records["abc"]
|
||||
second.ID, second.PublicID, second.UpstreamName = 101, "def", "cachedContents/up2"
|
||||
env.repo.records["def"] = &second
|
||||
env.apiKey.Quota = tc.quota
|
||||
enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: tc.balance})
|
||||
env.upstream.beforePost = func(n int) {
|
||||
if n == 1 {
|
||||
rec := env.patchTTL("def", "86400s")
|
||||
require.NotEqual(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusOK, env.patch().Code)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "第二笔续期没有到达上游")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅模式的确定金额费用按订阅维度预留:第一笔未入账时第二笔超出日剩余额度被拒绝,释放后放行。
|
||||
func TestReserveGeminiCachedContentCharge_UnsettledChargeHoldsSubscriptionHeadroom(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
limit := 110.0
|
||||
env.group.SubscriptionType = service.SubscriptionTypeSubscription
|
||||
env.group.DailyLimitUSD = &limit
|
||||
enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: 1000,
|
||||
sub: &service.SubscriptionCacheData{Status: service.SubscriptionStatusActive, ExpiresAt: time.Now().Add(time.Hour)}})
|
||||
sub := &service.UserSubscription{ID: 1, UserID: env.apiKey.UserID, GroupID: env.group.ID, Status: service.SubscriptionStatusActive,
|
||||
ExpiresAt: time.Now().Add(time.Hour)}
|
||||
reserve := func() (func(), error) {
|
||||
var done func()
|
||||
var err error
|
||||
env.serve(http.MethodPatch, "/", `{}`, nil, func(c *gin.Context) {
|
||||
done, err = env.h.reserveGeminiCachedContentCharge(c, env.apiKey, sub, 103.5)
|
||||
})
|
||||
return done, err
|
||||
}
|
||||
|
||||
firstDone, err := reserve()
|
||||
require.NoError(t, err)
|
||||
_, err = reserve()
|
||||
require.ErrorIs(t, err, service.ErrDailyLimitExceeded)
|
||||
firstDone()
|
||||
secondDone, err := reserve()
|
||||
require.NoError(t, err)
|
||||
secondDone()
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsPatch_UnaffordableExtensionRejectedBeforeUpstream(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
|
||||
rec := env.patch()
|
||||
require.NotEqual(t, http.StatusOK, rec.Code)
|
||||
require.Zero(t, env.upstream.methodCalls(http.MethodPatch), "余额不足以支付延长部分时不调用上游")
|
||||
require.Empty(t, env.usage.totals())
|
||||
}
|
||||
|
||||
// 上游对缓存不存在的响应:AI Studio 403;Vertex 缓存资源接口 404,文案含上游缓存数字 ID。
|
||||
var cachedBillingUpstreamNotFoundCases = []struct {
|
||||
name string
|
||||
failure cachedBillingUpstreamFailure
|
||||
}{
|
||||
{name: "ai studio 403", failure: cachedBillingUpstreamFailure{status: http.StatusForbidden,
|
||||
body: `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}`}},
|
||||
{name: "vertex 404", failure: cachedBillingUpstreamFailure{status: http.StatusNotFound,
|
||||
body: `{"error":{"code":404,"message":"Cached content 8079767076522164224 is not found.","status":"NOT_FOUND"}}`}},
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsPatch_UpstreamNotFoundForgetsAndReturnsStandardForbidden(t *testing.T) {
|
||||
for _, tc := range cachedBillingUpstreamNotFoundCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: tc.failure}
|
||||
|
||||
rec := env.patch()
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Equal(t, service.GeminiCachedContentNotFoundResponse, rec.Body.String())
|
||||
require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID")
|
||||
require.Equal(t, []int64{100}, env.repo.deleted, "上游已不存在的缓存应从本地移除")
|
||||
require.Empty(t, env.usage.totals(), "上游未延长不收费")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsDelete_UpstreamNotFoundStillForgets(t *testing.T) {
|
||||
for _, tc := range cachedBillingUpstreamNotFoundCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: tc.failure}
|
||||
|
||||
rec := env.serve(http.MethodDelete, "/v1beta/cachedContents/abc", "",
|
||||
gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsDelete)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.JSONEq(t, `{}`, rec.Body.String())
|
||||
require.Equal(t, []int64{100}, env.repo.deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 持有账号暂时不支持显式缓存(能力被关闭)时,恢复流程不能把修改意图当作已回滚而清除;
|
||||
// 能力恢复后再回滚上游并清除意图。
|
||||
func TestGeminiCachedContentMutationRecovery_KeepsIntentWhileAccountCannotCache(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
|
||||
baseline := time.Now().Add(time.Hour)
|
||||
env.seed(baseline)
|
||||
env.repo.failCommit = true
|
||||
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
|
||||
env.repo.failCommit = false
|
||||
env.repo.expireLease(100)
|
||||
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.Error(t, err, "账号暂不支持缓存时恢复失败")
|
||||
require.Zero(t, recovered)
|
||||
require.True(t, env.repo.pendingMutation(100), "未回滚上游前保留修改意图")
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游")
|
||||
require.Empty(t, env.repo.deleted)
|
||||
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
|
||||
env.repo.expireLease(100)
|
||||
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch))
|
||||
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
|
||||
require.False(t, env.repo.pendingMutation(100))
|
||||
require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline))
|
||||
require.Empty(t, env.usage.totals())
|
||||
}
|
||||
|
||||
// 持有账号长期不支持显式缓存,且修改前到期时间加上单次最长有效期都已过去:上游缓存必已自行过期,
|
||||
// 恢复流程不再保留意图,直接移除本地记录。
|
||||
func TestGeminiCachedContentMutationRecovery_AbandonsIntentOnceUpstreamMustHaveExpired(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.repo.failCommit = true
|
||||
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
|
||||
env.repo.failCommit = false
|
||||
env.repo.expireLease(100)
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
|
||||
|
||||
// 修改前到期时间 + 24h 上限 + 1h 容忍仍未过去:保留意图。
|
||||
env.repo.records["abc"].ExpireTime = time.Now().Add(-20 * time.Hour)
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Zero(t, recovered)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
require.Empty(t, env.repo.deleted)
|
||||
|
||||
env.repo.expireLease(100)
|
||||
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
|
||||
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游")
|
||||
require.Zero(t, env.upstream.methodCalls(http.MethodDelete))
|
||||
require.Equal(t, []int64{100}, env.repo.deleted)
|
||||
require.Empty(t, env.usage.totals())
|
||||
|
||||
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, recovered, "作废的记录不再被选中")
|
||||
}
|
||||
|
||||
// 作废判定的边界:默认上限 24h 加 1h 容忍;配置上限低于 24h 时仍按 24h 兜底。
|
||||
func TestGeminiCachedContentMutationRecovery_AbandonBoundary(t *testing.T) {
|
||||
disableCache := func(env *cachedBillingEnv) {
|
||||
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
|
||||
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
|
||||
}
|
||||
t.Run("within grace keeps intent", func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(-25*time.Hour + time.Minute))
|
||||
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
|
||||
disableCache(env)
|
||||
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Zero(t, recovered)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
require.Empty(t, env.repo.deleted)
|
||||
})
|
||||
t.Run("configured max ttl below default still waits a day", func(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(-12 * time.Hour))
|
||||
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
|
||||
disableCache(env)
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.GeminiCachedContentMaxTTLSeconds = 3600
|
||||
compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: env.accounts}, nil, nil, nil, nil, nil, env.upstream, nil, cfg)
|
||||
svc := service.NewGeminiCachedContentService(env.repo, compat, cfg)
|
||||
|
||||
recovered, err := svc.RecoverStaleMutations(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Zero(t, recovered)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
|
||||
env.repo.expireLease(100)
|
||||
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
|
||||
recovered, err = svc.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, []int64{100}, env.repo.deleted)
|
||||
})
|
||||
}
|
||||
|
||||
// 持有账号可用但上游回滚持续失败(如 Key 已失效):上游缓存必已自行过期后不再重试,移除本地记录。
|
||||
func TestGeminiCachedContentMutationRecovery_AbandonsIntentWhenRollbackKeepsFailing(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(-time.Minute))
|
||||
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
|
||||
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: {status: http.StatusUnauthorized,
|
||||
body: `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`}}
|
||||
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.Error(t, err, "上游缓存可能仍存活:保留意图等待重试")
|
||||
require.Zero(t, recovered)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete))
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
require.Empty(t, env.repo.deleted)
|
||||
|
||||
env.repo.expireLease(100)
|
||||
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
|
||||
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 2, env.upstream.methodCalls(http.MethodDelete), "账号可用时仍先尝试回滚")
|
||||
require.Equal(t, []int64{100}, env.repo.deleted)
|
||||
require.Empty(t, env.usage.totals())
|
||||
}
|
||||
|
||||
// 持有账号已删除时没有凭据可回滚上游:恢复流程移除本地记录并清除意图,不再重试。
|
||||
func TestGeminiCachedContentMutationRecovery_ForgetsRecordWhenAccountDeleted(t *testing.T) {
|
||||
env := newCachedBillingEnv(t, 1000, 900)
|
||||
env.seed(time.Now().Add(time.Hour))
|
||||
env.repo.failCommit = true
|
||||
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
|
||||
require.True(t, env.repo.pendingMutation(100))
|
||||
env.repo.failCommit = false
|
||||
env.repo.expireLease(100)
|
||||
|
||||
delete(env.accounts, 900)
|
||||
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, recovered)
|
||||
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "无凭据,不改动上游")
|
||||
require.Zero(t, env.upstream.methodCalls(http.MethodDelete))
|
||||
require.Equal(t, []int64{100}, env.repo.deleted, "本地记录作废")
|
||||
require.Empty(t, env.usage.totals())
|
||||
require.Equal(t, http.StatusForbidden, env.serve(http.MethodGet, "/v1beta/cachedContents/abc", "", gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsGet).Code, "作废的记录对客户端不可见")
|
||||
|
||||
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, recovered, "不再重复处理")
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// geminiCachedContentCreateMaxAttempts 限制创建缓存时实际发往上游的账号数。
|
||||
const geminiCachedContentCreateMaxAttempts = 3
|
||||
|
||||
// geminiCachedContentPersistTimeout 是上游操作完成后落库的超时;落库与客户端连接脱钩。
|
||||
const geminiCachedContentPersistTimeout = 15 * time.Second
|
||||
|
||||
const geminiCachedContentUnavailableMessage = "The account holding this cached content is temporarily unavailable"
|
||||
|
||||
// geminiCachedContentsPreamble 校验显式缓存管理接口的公共前置条件:gemini 分组、非 composite 分组、功能已装配。
|
||||
func (h *GatewayHandler) geminiCachedContentsPreamble(c *gin.Context, component string) (*service.APIKey, middleware.AuthSubject, *zap.Logger, bool) {
|
||||
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil {
|
||||
googleError(c, http.StatusUnauthorized, "Invalid API key")
|
||||
return nil, middleware.AuthSubject{}, nil, false
|
||||
}
|
||||
authSubject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
googleError(c, http.StatusInternalServerError, "User context not found")
|
||||
return nil, middleware.AuthSubject{}, nil, false
|
||||
}
|
||||
if h.geminiCachedContentService == nil {
|
||||
googlePlatformError(c, http.StatusNotFound, "Explicit context caching is not enabled")
|
||||
return nil, middleware.AuthSubject{}, nil, false
|
||||
}
|
||||
if apiKey.Group == nil || apiKey.GroupID == nil || apiKey.Group.Platform == service.PlatformComposite {
|
||||
googleError(c, http.StatusBadRequest, "Explicit context caching is not supported for this API key group")
|
||||
return nil, middleware.AuthSubject{}, nil, false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
|
||||
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
|
||||
return nil, middleware.AuthSubject{}, nil, false
|
||||
}
|
||||
reqLog := requestLogger(
|
||||
c,
|
||||
component,
|
||||
zap.Int64("user_id", authSubject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
return apiKey, authSubject, reqLog, true
|
||||
}
|
||||
|
||||
func readGeminiCachedContentBody(c *gin.Context) ([]byte, bool) {
|
||||
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
|
||||
if err != nil {
|
||||
if maxErr, ok := extractMaxBytesError(err); ok {
|
||||
googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit))
|
||||
return nil, false
|
||||
}
|
||||
googleError(c, http.StatusBadRequest, "Failed to read request body")
|
||||
return nil, false
|
||||
}
|
||||
if len(body) == 0 {
|
||||
googleError(c, http.StatusBadRequest, "Request body is empty")
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) checkGeminiCachedContentBilling(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subscription *service.UserSubscription) bool {
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("gemini.cached_content.billing_eligibility_check_failed", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GeminiCachedContentsCreate POST /v1beta/cachedContents
|
||||
// 创建请求内一次性收取缓存 token 的输入费与整个有效期的存储费。
|
||||
func (h *GatewayHandler) GeminiCachedContentsCreate(c *gin.Context) {
|
||||
apiKey, authSubject, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.create")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := h.geminiCachedContentService
|
||||
|
||||
body, ok := readGeminiCachedContentBody(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := service.ParseGeminiCachedContentCreateRequest(body, svc.Now(), svc.MaxTTL())
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
modelName := req.Model
|
||||
reqLog = reqLog.With(zap.String("model", modelName))
|
||||
|
||||
setOpsRequestContext(c, modelName, false)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage {
|
||||
googleSecurityAuditError(c, decision)
|
||||
return
|
||||
}
|
||||
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
|
||||
reqModel := modelName
|
||||
if channelMapping.Mapped {
|
||||
modelName = channelMapping.MappedModel
|
||||
}
|
||||
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
|
||||
streamStarted := false
|
||||
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, false, &streamStarted)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.cached_content.user_slot_acquire_failed", zap.Error(err))
|
||||
googleConcurrencyError(c, err, "user")
|
||||
return
|
||||
}
|
||||
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
|
||||
if userReleaseFunc != nil {
|
||||
defer userReleaseFunc()
|
||||
}
|
||||
|
||||
if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) {
|
||||
return
|
||||
}
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
|
||||
if err != nil {
|
||||
reqLog.Info("gemini.cached_content.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
excluded, err := h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, 0)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err))
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching")
|
||||
return
|
||||
}
|
||||
|
||||
var lastUpstreamErr *service.GeminiCachedContentUpstreamError
|
||||
var ttlRejectedLimit time.Duration
|
||||
for attempts := 0; attempts < geminiCachedContentCreateMaxAttempts; {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", modelName, excluded, "", int64(0))
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
reqLog.Info("gemini.cached_content.account_select_exhausted", zap.Error(err))
|
||||
break
|
||||
}
|
||||
account := selection.Account
|
||||
if !service.IsGeminiExplicitCacheAccount(account) {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
excluded[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
ttl, ttlAllowed := req.TTLForAccount(account)
|
||||
if !ttlAllowed {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
excluded[account.ID] = struct{}{}
|
||||
ttlRejectedLimit = max(ttlRejectedLimit, account.GeminiExplicitCacheUpstreamMaxTTL())
|
||||
continue
|
||||
}
|
||||
accountReleaseFunc, ok := h.acquireGeminiCachedContentAccountSlot(c, reqLog, geminiConcurrency, selection)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
accountReleaseFunc()
|
||||
reqLog.Debug("gemini.cached_content.account_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
excluded[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
attempts++
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
mappedModel := account.GetMappedModel(modelName)
|
||||
usageFields := clientRequestedUsageFields(c, channelMapping, reqModel, mappedModel)
|
||||
if !h.gatewayService.CacheStoragePriced(c.Request.Context(), &service.RecordUsageInput{
|
||||
Result: &service.ForwardResult{Model: modelName, UpstreamModel: mappedModel, CacheStorageTokenHours: 1},
|
||||
APIKey: apiKey,
|
||||
ChannelUsageFields: usageFields,
|
||||
}) {
|
||||
accountReleaseFunc()
|
||||
googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", modelName))
|
||||
return
|
||||
}
|
||||
upstreamModel, err := service.GeminiCachedContentUpstreamModelResource(account, mappedModel)
|
||||
if err != nil {
|
||||
accountReleaseFunc()
|
||||
reqLog.Warn("gemini.cached_content.upstream_model_invalid", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
excluded[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
publicID, err := service.NewGeminiCachedContentPublicID()
|
||||
if err != nil {
|
||||
accountReleaseFunc()
|
||||
googleError(c, http.StatusInternalServerError, "Failed to allocate cached content id")
|
||||
return
|
||||
}
|
||||
|
||||
upstream, err := svc.CreateUpstream(c.Request.Context(), account, req, mappedModel, publicID, ttl)
|
||||
accountReleaseFunc()
|
||||
if err != nil {
|
||||
var upErr *service.GeminiCachedContentUpstreamError
|
||||
if errors.As(err, &upErr) {
|
||||
reqLog.Info("gemini.cached_content.upstream_create_failed", zap.Int64("account_id", account.ID), zap.Int("status", upErr.StatusCode))
|
||||
if upErr.RetryableOnOtherAccount() {
|
||||
lastUpstreamErr = upErr
|
||||
excluded[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
h.writeGeminiCachedContentUpstreamError(c, upErr)
|
||||
return
|
||||
}
|
||||
// 上游已返回成功但响应不可用:不换号重建,能识别出资源名时删除上游缓存。
|
||||
var malformed *service.GeminiCachedContentMalformedResponseError
|
||||
if errors.As(err, &malformed) {
|
||||
reqLog.Error("gemini.cached_content.upstream_create_malformed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
if malformed.Name != "" {
|
||||
h.cleanupGeminiCachedContentUpstream(reqLog, account, malformed.Name)
|
||||
}
|
||||
googleError(c, http.StatusBadGateway, "Upstream returned an invalid cached content response")
|
||||
return
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
}
|
||||
reqLog.Warn("gemini.cached_content.upstream_create_error", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
excluded[account.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
// 缓存 token 数在上游创建后才确定:按确定金额校验余额 / 额度并预留,不足时删除上游缓存,
|
||||
// 只收取已发生的缓存 token 输入费。
|
||||
tokens := upstream.TotalTokenCount
|
||||
storageTokenHours := service.GeminiCachedContentStorageTokenHours(tokens, ttl)
|
||||
amount := h.gatewayService.QuoteUsageCost(c.Request.Context(), &service.RecordUsageInput{
|
||||
Result: &service.ForwardResult{
|
||||
Model: modelName, UpstreamModel: mappedModel,
|
||||
Usage: service.ClaudeUsage{InputTokens: int(tokens)}, CacheStorageTokenHours: storageTokenHours,
|
||||
},
|
||||
APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt, ChannelUsageFields: usageFields,
|
||||
})
|
||||
inflightRelease()
|
||||
chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, amount)
|
||||
if err != nil {
|
||||
reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Float64("amount", amount), zap.Error(err))
|
||||
h.cleanupGeminiCachedContentUpstream(reqLog, account, upstream.Name)
|
||||
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
|
||||
service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), 0, usageFields, body)
|
||||
writeGeminiCachedContentBillingError(c, err)
|
||||
return
|
||||
}
|
||||
defer chargeDone()
|
||||
|
||||
expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl))
|
||||
record := &service.GeminiCachedContent{
|
||||
PublicID: publicID,
|
||||
UserID: authSubject.UserID,
|
||||
APIKeyID: apiKey.ID,
|
||||
GroupID: *apiKey.GroupID,
|
||||
AccountID: account.ID,
|
||||
UpstreamName: upstream.Name,
|
||||
Model: modelName,
|
||||
UpstreamModel: upstreamModel,
|
||||
DisplayName: req.DisplayName,
|
||||
TotalTokenCount: tokens,
|
||||
ExpireTime: expireTime,
|
||||
ChannelUsage: usageFields,
|
||||
}
|
||||
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
|
||||
err = svc.Save(persistCtx, record)
|
||||
cancelPersist()
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.save_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
h.cleanupGeminiCachedContentUpstream(reqLog, account, record.UpstreamName)
|
||||
googleError(c, http.StatusInternalServerError, "Failed to persist cached content")
|
||||
return
|
||||
}
|
||||
|
||||
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
|
||||
service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), storageTokenHours, usageFields, body)
|
||||
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
|
||||
return
|
||||
}
|
||||
|
||||
if lastUpstreamErr != nil {
|
||||
h.writeGeminiCachedContentUpstreamError(c, lastUpstreamErr)
|
||||
return
|
||||
}
|
||||
if ttlRejectedLimit > 0 {
|
||||
googleError(c, http.StatusBadRequest, fmt.Sprintf("ttl exceeds the maximum allowed %ds by the upstream accounts", int64(ttlRejectedLimit.Seconds())))
|
||||
return
|
||||
}
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching")
|
||||
}
|
||||
|
||||
// GeminiCachedContentsList GET /v1beta/cachedContents
|
||||
// 只返回当前 API Key 在当前分组下仍有效的缓存,不透传上游 list(同一账号上其他租户的缓存不可见)。
|
||||
func (h *GatewayHandler) GeminiCachedContentsList(c *gin.Context) {
|
||||
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.list")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pageSize := service.GeminiCachedContentListDefaultPageSize
|
||||
if raw := strings.TrimSpace(c.Query("pageSize")); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 {
|
||||
googleError(c, http.StatusBadRequest, "pageSize must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
pageSize = min(n, service.GeminiCachedContentListMaxPageSize)
|
||||
}
|
||||
}
|
||||
beforeID := int64(0)
|
||||
if raw := strings.TrimSpace(c.Query("pageToken")); raw != "" {
|
||||
id, ok := decodeGeminiCachedContentPageToken(raw)
|
||||
if !ok {
|
||||
googleError(c, http.StatusBadRequest, "Invalid pageToken")
|
||||
return
|
||||
}
|
||||
beforeID = id
|
||||
}
|
||||
records, err := h.geminiCachedContentService.List(c.Request.Context(), apiKey.ID, *apiKey.GroupID, beforeID, pageSize+1)
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.list_failed", zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to list cached contents")
|
||||
return
|
||||
}
|
||||
resp := gin.H{}
|
||||
if len(records) > pageSize {
|
||||
records = records[:pageSize]
|
||||
resp["nextPageToken"] = encodeGeminiCachedContentPageToken(records[len(records)-1].ID)
|
||||
}
|
||||
if len(records) > 0 {
|
||||
views := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
views = append(views, service.GeminiCachedContentView(record))
|
||||
}
|
||||
resp["cachedContents"] = views
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GeminiCachedContentsGet GET /v1beta/cachedContents/:cacheID
|
||||
func (h *GatewayHandler) GeminiCachedContentsGet(c *gin.Context) {
|
||||
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.get")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
|
||||
}
|
||||
|
||||
// GeminiCachedContentsPatch PATCH /v1beta/cachedContents/:cacheID
|
||||
// 只允许修改有效期;延长部分在本次请求内按增量时长收取存储费,缩短不退费。
|
||||
// 同一缓存的修改串行执行:上游修改、本地写回与计费都在修改意图的租约内完成,并发的修改返回 409。
|
||||
// 上游结果未知或本地写回失败的修改不计费,租约过期后由恢复流程把上游有效期回滚到修改前的到期时间。
|
||||
func (h *GatewayHandler) GeminiCachedContentsPatch(c *gin.Context) {
|
||||
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.patch")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := h.geminiCachedContentService
|
||||
body, ok := readGeminiCachedContentBody(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
account, err := svc.BoundAccount(c.Request.Context(), record)
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to load cached content account")
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
||||
return
|
||||
}
|
||||
ttl, err := service.ParseGeminiCachedContentPatchRequest(body, c.Query("updateMask"), svc.Now(), svc.MaxTTLForAccount(account))
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
setOpsRequestContext(c, record.Model, false)
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
if extension := svc.Now().Add(ttl).Sub(record.ExpireTime); extension > 0 {
|
||||
usage := &service.RecordUsageInput{
|
||||
Result: &service.ForwardResult{
|
||||
Model: record.Model, UpstreamModel: record.UpstreamModelID(),
|
||||
CacheStorageTokenHours: service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extension),
|
||||
},
|
||||
APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt,
|
||||
ChannelUsageFields: record.ChannelUsage,
|
||||
}
|
||||
if !h.gatewayService.CacheStoragePriced(c.Request.Context(), usage) {
|
||||
googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", record.Model))
|
||||
return
|
||||
}
|
||||
if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) {
|
||||
return
|
||||
}
|
||||
chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, h.gatewayService.QuoteUsageCost(c.Request.Context(), usage))
|
||||
if err != nil {
|
||||
reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Error(err))
|
||||
writeGeminiCachedContentBillingError(c, err)
|
||||
return
|
||||
}
|
||||
defer chargeDone()
|
||||
}
|
||||
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
mutation, err := svc.BeginMutation(c.Request.Context(), record)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGeminiCachedContentNotFound):
|
||||
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
|
||||
case errors.Is(err, service.ErrGeminiCachedContentMutationConflict):
|
||||
reqLog.Info("gemini.cached_content.mutation_conflict", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
writeGeminiCachedContentMutationConflict(c)
|
||||
default:
|
||||
reqLog.Error("gemini.cached_content.begin_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to update cached content")
|
||||
}
|
||||
return
|
||||
}
|
||||
upstream, err := svc.PatchUpstream(c.Request.Context(), account, record, ttl)
|
||||
if err != nil {
|
||||
var upErr *service.GeminiCachedContentUpstreamError
|
||||
upstreamRejected := errors.As(err, &upErr)
|
||||
if upstreamRejected || errors.Is(err, service.ErrGeminiCachedContentRequestNotSent) {
|
||||
// 上游有效期未变:清除修改意图。
|
||||
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
|
||||
if releaseErr := svc.ReleaseMutation(persistCtx, mutation); releaseErr != nil {
|
||||
reqLog.Warn("gemini.cached_content.release_mutation_failed", zap.Error(releaseErr))
|
||||
}
|
||||
cancelPersist()
|
||||
}
|
||||
if upstreamRejected {
|
||||
if upErr.NotFound() {
|
||||
if forgetErr := svc.Forget(c.Request.Context(), record); forgetErr != nil {
|
||||
reqLog.Warn("gemini.cached_content.forget_failed", zap.Error(forgetErr))
|
||||
}
|
||||
}
|
||||
h.writeGeminiCachedContentUpstreamError(c, upErr)
|
||||
return
|
||||
}
|
||||
reqLog.Warn("gemini.cached_content.upstream_patch_error", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
googleError(c, http.StatusBadGateway, "Upstream request failed")
|
||||
return
|
||||
}
|
||||
expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl))
|
||||
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
|
||||
err = svc.CommitMutation(persistCtx, mutation, expireTime)
|
||||
cancelPersist()
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.commit_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
if errors.Is(err, service.ErrGeminiCachedContentMutationConflict) {
|
||||
writeGeminiCachedContentMutationConflict(c)
|
||||
return
|
||||
}
|
||||
googleError(c, http.StatusInternalServerError, "Failed to persist cached content expiration")
|
||||
return
|
||||
}
|
||||
// 提交前库中的到期时间一直是修改前的值:按本次实际推进的时长收费。
|
||||
if extended := expireTime.Sub(mutation.Baseline()); extended > 0 {
|
||||
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
|
||||
service.GeminiCachedContentPatchUsageRequestID(record.PublicID), record.Model, record.UpstreamModelID(), 0,
|
||||
service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extended), record.ChannelUsage, body)
|
||||
}
|
||||
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
|
||||
}
|
||||
|
||||
// GeminiCachedContentsDelete DELETE /v1beta/cachedContents/:cacheID
|
||||
// 删除不退还已收取的存储费。
|
||||
func (h *GatewayHandler) GeminiCachedContentsDelete(c *gin.Context) {
|
||||
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.delete")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := h.geminiCachedContentService
|
||||
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
account, err := svc.BoundAccount(c.Request.Context(), record)
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to load cached content account")
|
||||
return
|
||||
}
|
||||
if account != nil {
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
if err := svc.DeleteUpstream(c.Request.Context(), account, record); err != nil {
|
||||
var upErr *service.GeminiCachedContentUpstreamError
|
||||
if !errors.As(err, &upErr) {
|
||||
reqLog.Warn("gemini.cached_content.upstream_delete_error", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
googleError(c, http.StatusBadGateway, "Upstream request failed")
|
||||
return
|
||||
}
|
||||
if !upErr.NotFound() {
|
||||
h.writeGeminiCachedContentUpstreamError(c, upErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
|
||||
err = svc.Forget(persistCtx, record)
|
||||
cancelPersist()
|
||||
if err != nil {
|
||||
reqLog.Error("gemini.cached_content.forget_failed", zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to delete cached content")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) resolveGeminiCachedContentOrWrite(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, cacheID string) (*service.GeminiCachedContent, bool) {
|
||||
record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, cacheID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrGeminiCachedContentNotFound) {
|
||||
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
|
||||
return nil, false
|
||||
}
|
||||
reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to load cached content")
|
||||
return nil, false
|
||||
}
|
||||
return record, true
|
||||
}
|
||||
|
||||
// resolveGeminiCachedContentBinding 处理 generateContent / countTokens 请求对显式缓存的引用:
|
||||
// 校验归属与模型后把引用改写为上游资源名,返回绑定记录;请求未引用缓存时返回 nil。
|
||||
// 返回 false 表示已写出错误响应。
|
||||
func (h *GatewayHandler) resolveGeminiCachedContentBinding(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, modelName string, body []byte) (*service.GeminiCachedContent, []byte, bool) {
|
||||
ref, err := service.FindGeminiCachedContentReference(body)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadRequest, err.Error())
|
||||
return nil, nil, false
|
||||
}
|
||||
if ref == nil {
|
||||
return nil, body, true
|
||||
}
|
||||
if middleware.HasForcePlatform(c) || h.geminiCachedContentService == nil ||
|
||||
apiKey.Group == nil || apiKey.Group.Platform == service.PlatformComposite {
|
||||
googleError(c, http.StatusBadRequest, "cachedContent is not supported on this route")
|
||||
return nil, nil, false
|
||||
}
|
||||
record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, ref.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrGeminiCachedContentNotFound) {
|
||||
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
|
||||
return nil, nil, false
|
||||
}
|
||||
reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err))
|
||||
googleError(c, http.StatusInternalServerError, "Failed to load cached content")
|
||||
return nil, nil, false
|
||||
}
|
||||
if record.Model != modelName {
|
||||
googleError(c, http.StatusBadRequest, fmt.Sprintf(
|
||||
"Model used by GenerateContent request (models/%s) and CachedContent (models/%s) has to be the same.", modelName, record.Model))
|
||||
return nil, nil, false
|
||||
}
|
||||
rewritten, err := service.RewriteGeminiCachedContentReference(body, ref, record.UpstreamName)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadRequest, "Invalid cachedContent reference")
|
||||
return nil, nil, false
|
||||
}
|
||||
return record, rewritten, true
|
||||
}
|
||||
|
||||
// acquireGeminiCachedContentAccountSlot 获取选中账号的并发槽(含等待计划);返回 false 表示已写出错误响应。
|
||||
func (h *GatewayHandler) acquireGeminiCachedContentAccountSlot(c *gin.Context, reqLog *zap.Logger, helper *ConcurrencyHelper, selection *service.AccountSelectionResult) (func(), bool) {
|
||||
if selection.Acquired {
|
||||
release := selection.ReleaseFunc
|
||||
if release == nil {
|
||||
release = func() {}
|
||||
}
|
||||
return wrapReleaseOnDone(c.Request.Context(), release), true
|
||||
}
|
||||
account := selection.Account
|
||||
if selection.WaitPlan == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
|
||||
return nil, false
|
||||
}
|
||||
canWait, err := helper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.cached_content.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
} else if !canWait {
|
||||
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
|
||||
return nil, false
|
||||
}
|
||||
streamStarted := false
|
||||
release, acquireErr := helper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted)
|
||||
if err == nil {
|
||||
helper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
||||
}
|
||||
if acquireErr != nil {
|
||||
reqLog.Warn("gemini.cached_content.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(acquireErr))
|
||||
googleConcurrencyError(c, acquireErr, "account")
|
||||
return nil, false
|
||||
}
|
||||
if release == nil {
|
||||
release = func() {}
|
||||
}
|
||||
return wrapReleaseOnDone(c.Request.Context(), release), true
|
||||
}
|
||||
|
||||
// submitGeminiCachedContentUsage 记录缓存创建 / 延长的计费:inputTokens 按输入价计,storageTokenHours 按计费模型的存储单价计;
|
||||
// requestID 是带固定前缀的费用行标识(见 GeminiCachedContentUsageRequestIDPrefix),落库时不被请求 id 覆盖。
|
||||
func (h *GatewayHandler) submitGeminiCachedContentUsage(
|
||||
c *gin.Context,
|
||||
apiKey *service.APIKey,
|
||||
account *service.Account,
|
||||
subscription *service.UserSubscription,
|
||||
pricingAt time.Time,
|
||||
requestID string,
|
||||
model string,
|
||||
upstreamModel string,
|
||||
inputTokens int,
|
||||
storageTokenHours float64,
|
||||
usageFields service.ChannelUsageFields,
|
||||
payload []byte,
|
||||
) {
|
||||
if inputTokens <= 0 && storageTokenHours <= 0 {
|
||||
return
|
||||
}
|
||||
if upstreamModel == model {
|
||||
upstreamModel = ""
|
||||
}
|
||||
result := &service.ForwardResult{
|
||||
RequestID: requestID,
|
||||
Model: model,
|
||||
UpstreamModel: upstreamModel,
|
||||
Usage: service.ClaudeUsage{InputTokens: inputTokens},
|
||||
CacheStorageTokenHours: storageTokenHours,
|
||||
}
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(payload)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
APIKeyService: h.apiKeyService,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
ChannelUsageFields: usageFields,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gemini_v1beta.cached_contents"),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("request_id", result.RequestID),
|
||||
).Error("gemini.cached_content.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// geminiCachedContentPersistContext 返回与客户端连接脱钩的落库 context:上游操作已生效时,客户端断开不应让本地记录缺失。
|
||||
func geminiCachedContentPersistContext(c *gin.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(c.Request.Context()), geminiCachedContentPersistTimeout)
|
||||
}
|
||||
|
||||
// cleanupGeminiCachedContentUpstream 尽力删除已在上游创建、但网关不会登记的缓存,避免其持续产生存储费。
|
||||
func (h *GatewayHandler) cleanupGeminiCachedContentUpstream(reqLog *zap.Logger, account *service.Account, upstreamName string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), geminiCachedContentPersistTimeout)
|
||||
defer cancel()
|
||||
if err := h.geminiCachedContentService.DeleteUpstream(ctx, account, &service.GeminiCachedContent{UpstreamName: upstreamName}); err != nil {
|
||||
reqLog.Error("gemini.cached_content.orphan_cleanup_failed", zap.Int64("account_id", account.ID), zap.String("upstream_name", upstreamName), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// reserveGeminiCachedContentCharge 校验余额 / 订阅额度 / API Key 额度足以支付确定金额 amount(倍率后),
|
||||
// 并按该金额在用户、订阅、API Key 维度登记在途预留(不受预留上限封顶,见 ReserveFixedCharge)。
|
||||
// 返回的 done 须在 handler 结束时调用;预留随计费任务交接,落库后释放。
|
||||
func (h *GatewayHandler) reserveGeminiCachedContentCharge(c *gin.Context, apiKey *service.APIKey, subscription *service.UserSubscription, amount float64) (func(), error) {
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.billingCacheService.ReserveFixedCharge(ctx, apiKey.User, apiKey, apiKey.Group, subscription, amount)
|
||||
if err != nil {
|
||||
return inflightNoop, err
|
||||
}
|
||||
if res == nil {
|
||||
return inflightNoop, nil
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithInflightReservation(ctx, res))
|
||||
return res.HandlerDone, nil
|
||||
}
|
||||
|
||||
// writeGeminiCachedContentMutationConflict 回写同一缓存的修改冲突(Google 并发冲突语义 ABORTED),客户端可稍后重试。
|
||||
func writeGeminiCachedContentMutationConflict(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": gin.H{
|
||||
"code": http.StatusConflict,
|
||||
"message": "Another update to this cached content is in progress, please retry later",
|
||||
"status": "ABORTED",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func writeGeminiCachedContentBillingError(c *gin.Context, err error) {
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
}
|
||||
|
||||
// writeGeminiCachedContentUpstreamError 回写上游错误:缓存不存在统一返回标准 403(各上游形态不同且可能含上游资源 ID);
|
||||
// 账号凭据或权限类错误不是客户端问题,统一改为 502;限流 / 不可用按普通 Gemini 路径处理(透传规则、通用映射),
|
||||
// 只有上游网关账号的这类响应反映持有缓存的上游账号状态,原样回写。
|
||||
func (h *GatewayHandler) writeGeminiCachedContentUpstreamError(c *gin.Context, upErr *service.GeminiCachedContentUpstreamError) {
|
||||
status := upErr.StatusCode
|
||||
message := strings.TrimSpace(extractGoogleErrorMessage(upErr.Body))
|
||||
service.SetOpsUpstreamError(c, status, message, "")
|
||||
if upErr.NotFound() {
|
||||
service.MarkOpsRequestScopedError(c, "permission_error")
|
||||
c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse))
|
||||
return
|
||||
}
|
||||
if (status == http.StatusTooManyRequests || status >= http.StatusInternalServerError) && !upErr.UpstreamGateway {
|
||||
h.handleGeminiFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: status, ResponseBody: upErr.Body})
|
||||
return
|
||||
}
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
googleError(c, http.StatusBadGateway, "Upstream account rejected the cached content request")
|
||||
return
|
||||
}
|
||||
if len(upErr.Body) == 0 || !json.Valid(upErr.Body) {
|
||||
if message == "" {
|
||||
message = http.StatusText(status)
|
||||
}
|
||||
googleError(c, status, message)
|
||||
return
|
||||
}
|
||||
c.Data(status, "application/json", upErr.Body)
|
||||
}
|
||||
|
||||
func extractGoogleErrorMessage(body []byte) string {
|
||||
var envelope struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return ""
|
||||
}
|
||||
return envelope.Error.Message
|
||||
}
|
||||
|
||||
func encodeGeminiCachedContentPageToken(id int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
|
||||
}
|
||||
|
||||
func decodeGeminiCachedContentPageToken(raw string) (int64, bool) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(string(decoded), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// mergeAccountIDSets 返回两个账号 ID 集合的并集(不修改入参)。
|
||||
func mergeAccountIDSets(a, b map[int64]struct{}) map[int64]struct{} {
|
||||
out := make(map[int64]struct{}, len(a)+len(b))
|
||||
for id := range a {
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
for id := range b {
|
||||
out[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleGeminiCachedContentFailoverExhausted 处理绑定缓存的请求在持有账号上重试耗尽:没有其他账号可换。
|
||||
// 上游网关账号的限流 / 不可用响应反映持有缓存的上游账号状态,原样返回供客户端判断;
|
||||
// 其余(含官方账号)仍按普通 Gemini 路径处理:透传规则、通用映射。
|
||||
func (h *GatewayHandler) handleGeminiCachedContentFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError) {
|
||||
if failoverErr == nil || !failoverErr.BoundUpstreamPassthrough || len(failoverErr.ResponseBody) == 0 || !json.Valid(failoverErr.ResponseBody) {
|
||||
h.handleGeminiFailoverExhausted(c, failoverErr)
|
||||
return
|
||||
}
|
||||
service.SetOpsUpstreamError(c, failoverErr.StatusCode, service.ExtractUpstreamErrorMessage(failoverErr.ResponseBody), "")
|
||||
c.Data(failoverErr.StatusCode, "application/json", failoverErr.ResponseBody)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type geminiCachedContentHandlerRepoStub struct {
|
||||
records []*service.GeminiCachedContent
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) Create(_ context.Context, record *service.GeminiCachedContent) error {
|
||||
record.ID = int64(len(r.records) + 1)
|
||||
r.records = append(r.records, record)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) {
|
||||
for _, record := range r.records {
|
||||
if record.PublicID == publicID && record.APIKeyID == apiKeyID {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return nil, service.ErrGeminiCachedContentNotFound
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) ListForOwner(_ context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*service.GeminiCachedContent, error) {
|
||||
var out []*service.GeminiCachedContent
|
||||
for i := len(r.records) - 1; i >= 0; i-- {
|
||||
record := r.records[i]
|
||||
if record.APIKeyID == apiKeyID && record.GroupID == groupID && record.ExpireTime.After(activeAt) && (beforeID <= 0 || record.ID < beforeID) {
|
||||
out = append(out, record)
|
||||
}
|
||||
if len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) BeginMutation(context.Context, int64, time.Time, string, time.Duration) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) TakeOverStaleMutation(context.Context, int64, string, time.Duration) (*service.GeminiCachedContent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) CommitMutation(context.Context, int64, string, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) ReleaseMutation(context.Context, int64, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) ListStaleMutations(context.Context, int) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *geminiCachedContentHandlerRepoStub) SoftDelete(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGeminiCachedContentHandlerForTest(repo *geminiCachedContentHandlerRepoStub) *GatewayHandler {
|
||||
return &GatewayHandler{geminiCachedContentService: service.NewGeminiCachedContentService(repo, nil, &config.Config{})}
|
||||
}
|
||||
|
||||
func geminiCachedContentTestAPIKey(platform string) *service.APIKey {
|
||||
groupID := int64(42)
|
||||
return &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: platform}}
|
||||
}
|
||||
|
||||
func newGeminiCachedContentTestContext(method, target string, apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(method, target, nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func seedGeminiCachedContent(repo *geminiCachedContentHandlerRepoStub, publicID string, apiKeyID int64, expire time.Time) *service.GeminiCachedContent {
|
||||
record := &service.GeminiCachedContent{
|
||||
PublicID: publicID,
|
||||
UserID: 3,
|
||||
APIKeyID: apiKeyID,
|
||||
GroupID: 42,
|
||||
AccountID: 900,
|
||||
UpstreamName: "cachedContents/upstream" + publicID,
|
||||
Model: "gemini-3.8-flash",
|
||||
UpstreamModel: "models/gemini-3.8-flash",
|
||||
TotalTokenCount: 6273,
|
||||
ExpireTime: expire,
|
||||
CreatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
_ = repo.Create(context.Background(), record)
|
||||
return record
|
||||
}
|
||||
|
||||
func TestResolveGeminiCachedContentBinding(t *testing.T) {
|
||||
repo := &geminiCachedContentHandlerRepoStub{}
|
||||
seedGeminiCachedContent(repo, "abc", 7, time.Now().Add(time.Hour))
|
||||
seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour))
|
||||
h := newGeminiCachedContentHandlerForTest(repo)
|
||||
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
|
||||
|
||||
t.Run("request without cache passes through", func(t *testing.T) {
|
||||
c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
body := []byte(`{"contents":[]}`)
|
||||
record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash", body)
|
||||
require.True(t, ok)
|
||||
require.Nil(t, record)
|
||||
require.Equal(t, body, out)
|
||||
})
|
||||
|
||||
t.Run("owned cache rewrites reference and binds account", func(t *testing.T) {
|
||||
c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash",
|
||||
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, int64(900), record.AccountID)
|
||||
require.JSONEq(t, `{"cachedContent":"cachedContents/upstreamabc","contents":[]}`, string(out))
|
||||
})
|
||||
|
||||
t.Run("cache owned by another key looks missing", func(t *testing.T) {
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash",
|
||||
[]byte(`{"cachedContent":"cachedContents/foreign","contents":[]}`))
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), service.GeminiCachedContentNotFoundMessage)
|
||||
})
|
||||
|
||||
t.Run("model must match the cache literally", func(t *testing.T) {
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-flash-latest:generateContent", apiKey)
|
||||
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-flash-latest",
|
||||
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "has to be the same")
|
||||
})
|
||||
|
||||
t.Run("composite groups are rejected", func(t *testing.T) {
|
||||
composite := geminiCachedContentTestAPIKey(service.PlatformComposite)
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", composite)
|
||||
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), composite, "gemini-3.8-flash",
|
||||
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGeminiCachedContentsListAndGet(t *testing.T) {
|
||||
repo := &geminiCachedContentHandlerRepoStub{}
|
||||
seedGeminiCachedContent(repo, "first", 7, time.Now().Add(time.Hour))
|
||||
seedGeminiCachedContent(repo, "second", 7, time.Now().Add(time.Hour))
|
||||
seedGeminiCachedContent(repo, "expired", 7, time.Now().Add(-time.Minute))
|
||||
seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour))
|
||||
h := newGeminiCachedContentHandlerForTest(repo)
|
||||
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
|
||||
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1", apiKey)
|
||||
h.GeminiCachedContentsList(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var page struct {
|
||||
CachedContents []map[string]any `json:"cachedContents"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page))
|
||||
require.Len(t, page.CachedContents, 1)
|
||||
require.Equal(t, "cachedContents/second", page.CachedContents[0]["name"])
|
||||
require.NotEmpty(t, page.NextPageToken)
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1&pageToken="+page.NextPageToken, apiKey)
|
||||
h.GeminiCachedContentsList(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
page.NextPageToken = ""
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page))
|
||||
require.Len(t, page.CachedContents, 1)
|
||||
require.Equal(t, "cachedContents/first", page.CachedContents[0]["name"])
|
||||
require.Empty(t, page.NextPageToken, "过期与其他 Key 的缓存不出现在列表里")
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/first", apiKey)
|
||||
c.Params = gin.Params{{Key: "cacheID", Value: "first"}}
|
||||
h.GeminiCachedContentsGet(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var view map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &view))
|
||||
require.Equal(t, "cachedContents/first", view["name"])
|
||||
require.Equal(t, "models/gemini-3.8-flash", view["model"])
|
||||
require.NotContains(t, rec.Body.String(), "upstreamfirst", "上游资源名不暴露给客户端")
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/foreign", apiKey)
|
||||
c.Params = gin.Params{{Key: "cacheID", Value: "foreign"}}
|
||||
h.GeminiCachedContentsGet(c)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.True(t, strings.Contains(rec.Body.String(), service.GeminiCachedContentNotFoundMessage))
|
||||
}
|
||||
|
||||
func TestHandleGeminiCachedContentFailoverExhausted(t *testing.T) {
|
||||
h := &GatewayHandler{}
|
||||
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
|
||||
unavailable := `{"error":{"code":503,"message":"The account holding this cached content is temporarily unavailable","status":"UNAVAILABLE"}}`
|
||||
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusServiceUnavailable, ResponseBody: []byte(unavailable), BoundUpstreamPassthrough: true})
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code, "绑定缓存的请求无账号可换,上游网关的 503 原样返回")
|
||||
require.JSONEq(t, unavailable, rec.Body.String())
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(`{"error":{"code":429,"message":"Too many pending requests, please retry later","status":"RESOURCE_EXHAUSTED"}}`), BoundUpstreamPassthrough: true})
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "Too many pending requests")
|
||||
|
||||
officialQuota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}]}}`
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(officialQuota)})
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code, "官方账号的限流按普通 Gemini 路径映射")
|
||||
require.NotContains(t, rec.Body.String(), "FreeTier", "不向客户端暴露上游账号的配额明细")
|
||||
require.Contains(t, rec.Body.String(), "Upstream rate limit exceeded")
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusUnauthorized, ResponseBody: []byte(`{"error":{"code":401,"message":"bad key"}}`)})
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code, "账号凭据类错误仍按网关映射,不透传给客户端")
|
||||
require.NotContains(t, rec.Body.String(), "bad key")
|
||||
}
|
||||
|
||||
func TestWriteGeminiCachedContentUpstreamError_RateLimitMappedUnlessUpstreamGateway(t *testing.T) {
|
||||
h := &GatewayHandler{}
|
||||
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
|
||||
quota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}}`
|
||||
|
||||
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
|
||||
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota)})
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
require.NotContains(t, rec.Body.String(), "FreeTier", "官方账号的限流不透传配额明细")
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
|
||||
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota), UpstreamGateway: true})
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
require.JSONEq(t, quota, rec.Body.String(), "上游网关账号的限流反映持有缓存的上游账号状态,原样回写")
|
||||
|
||||
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
|
||||
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":{"code":400,"message":"Cached content is too small","status":"INVALID_ARGUMENT"}}`)})
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "too small", "请求校验类错误仍原样回写")
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
if len(agModels) > 0 {
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
|
||||
@@ -85,7 +85,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models")
|
||||
res, err := h.geminiCompatService.ForwardListModels(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -251,7 +251,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
// 没有 gemini 账户,检查是否有 antigravity 账户可用
|
||||
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
|
||||
@@ -265,7 +265,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName)
|
||||
res, err := h.geminiCompatService.ForwardGetModel(c.Request.Context(), account, modelName)
|
||||
if err != nil {
|
||||
googleError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
@@ -356,6 +356,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
modelName = channelMapping.MappedModel
|
||||
}
|
||||
|
||||
// 引用显式缓存的请求只能发往持有该缓存的账号,且不参与会话粘性与换号。
|
||||
cachedContentBinding, body, ok := h.resolveGeminiCachedContentBinding(c, reqLog, apiKey, modelName, body)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Get subscription (may be nil)
|
||||
subscription, _ := middleware.GetSubscriptionFromContext(c)
|
||||
|
||||
@@ -382,18 +388,34 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
// 2) billing eligibility check (after wait)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("gemini.billing_eligibility_check_failed", zap.Error(err))
|
||||
status, _, message, retryAfter := billingErrorDetails(err)
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
googleError(c, status, message)
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
|
||||
if err != nil {
|
||||
reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
// 3) select account (sticky session based on request body)
|
||||
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
|
||||
sessionHash := extractGeminiCLISessionHash(c, body)
|
||||
if sessionHash == "" {
|
||||
sessionHash := ""
|
||||
if cachedContentBinding == nil {
|
||||
sessionHash = extractGeminiCLISessionHash(c, body)
|
||||
}
|
||||
if sessionHash == "" && cachedContentBinding == nil {
|
||||
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
|
||||
parsedReq, _ := service.ParseGatewayRequest(service.NewRequestBodyRef(body), domain.PlatformGemini)
|
||||
if parsedReq != nil {
|
||||
@@ -430,7 +452,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
var geminiPrefixHash string
|
||||
var geminiSessionUUID string
|
||||
var matchedDigestChain string
|
||||
useDigestFallback := sessionBoundAccountID == 0
|
||||
useDigestFallback := sessionBoundAccountID == 0 && cachedContentBinding == nil
|
||||
|
||||
if useDigestFallback {
|
||||
// 解析 Gemini 请求体
|
||||
@@ -494,7 +516,21 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0
|
||||
cleanedForUnknownBinding := false
|
||||
|
||||
fs := NewFailoverState(h.maxAccountSwitchesGemini, hasBoundSession)
|
||||
maxAccountSwitches := h.maxAccountSwitchesGemini
|
||||
var cachedContentExclusions map[int64]struct{}
|
||||
if cachedContentBinding != nil {
|
||||
// 缓存只存在于绑定账号:排除分组内其余账号,失败不换号。
|
||||
maxAccountSwitches = 0
|
||||
sessionBoundAccountID = cachedContentBinding.AccountID
|
||||
c.Request = c.Request.WithContext(service.WithGeminiCachedContentBound(c.Request.Context()))
|
||||
cachedContentExclusions, err = h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, cachedContentBinding.AccountID)
|
||||
if err != nil {
|
||||
reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err))
|
||||
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
fs := NewFailoverState(maxAccountSwitches, hasBoundSession)
|
||||
|
||||
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
|
||||
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
|
||||
@@ -504,12 +540,32 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
for {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
excludedAccountIDs := fs.FailedAccountIDs
|
||||
if cachedContentExclusions != nil {
|
||||
excludedAccountIDs = mergeAccountIDSets(cachedContentExclusions, fs.FailedAccountIDs)
|
||||
}
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, excludedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err == nil && cachedContentBinding != nil && selection.Account.ID != cachedContentBinding.AccountID {
|
||||
if selection.Acquired && selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
err = service.ErrNoAvailableAccounts
|
||||
}
|
||||
if err != nil {
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if cachedContentBinding != nil {
|
||||
if fs.LastFailoverErr != nil {
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
|
||||
return
|
||||
}
|
||||
reqLog.Info("gemini.cached_content.bound_account_unavailable", zap.Int64("account_id", cachedContentBinding.AccountID), zap.Error(err))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
||||
return
|
||||
}
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
@@ -618,6 +674,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if cachedContentBinding != nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
|
||||
return
|
||||
}
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
@@ -671,6 +732,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
case FailoverExhausted:
|
||||
if cachedContentBinding != nil {
|
||||
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
|
||||
return
|
||||
}
|
||||
h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr)
|
||||
return
|
||||
case FailoverCanceled:
|
||||
@@ -796,7 +861,8 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE
|
||||
c.Set(service.OpsSkipPassthroughKey, true)
|
||||
}
|
||||
|
||||
googleError(c, respCode, msg)
|
||||
// 按透传规则改写的是上游错误,不登记本地错误类型。
|
||||
googleErrorWithType(c, respCode, "", "", msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -831,7 +897,21 @@ type pathParseError struct{ msg string }
|
||||
|
||||
func (e *pathParseError) Error() string { return e.msg }
|
||||
|
||||
// googleError 以 Google 错误格式回写网关产生的错误,并按状态码向 ops 登记错误类型:
|
||||
// 4xx 为客户端请求问题,其余状态码不登记(按平台错误统计)。
|
||||
func googleError(c *gin.Context, status int, message string) {
|
||||
googleErrorWithType(c, status, googleClientErrorType(status), "", message)
|
||||
}
|
||||
|
||||
// googlePlatformError 以 Google 错误格式回写由平台侧原因(如管理员未完成配置)导致的错误,ops 按平台错误统计。
|
||||
func googlePlatformError(c *gin.Context, status int, message string) {
|
||||
googleErrorWithType(c, status, "", "", message)
|
||||
}
|
||||
|
||||
// googleErrorWithType 以 Google 错误格式回写错误,并向 ops 登记错误类型与错误码(Google 错误体没有 type 字段),
|
||||
// 与 Claude / OpenAI 格式错误同口径分类;errType 为空时不登记。
|
||||
func googleErrorWithType(c *gin.Context, status int, errType, code, message string) {
|
||||
service.SetOpsLocalErrorType(c, errType, code)
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": status,
|
||||
@@ -841,11 +921,27 @@ func googleError(c *gin.Context, status int, message string) {
|
||||
})
|
||||
}
|
||||
|
||||
// googleClientErrorType 返回客户端请求类 4xx 状态码对应的 ops 错误类型;其余状态码返回空串。
|
||||
func googleClientErrorType(status int) string {
|
||||
switch status {
|
||||
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
|
||||
return "invalid_request_error"
|
||||
case http.StatusUnauthorized:
|
||||
return "authentication_error"
|
||||
case http.StatusForbidden:
|
||||
return "permission_error"
|
||||
case http.StatusNotFound:
|
||||
return "not_found_error"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
|
||||
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
|
||||
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
|
||||
status, _, _, message := concurrencyErrorResponse(err, slotType)
|
||||
googleError(c, status, message)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
||||
googleErrorWithType(c, status, errType, code, message)
|
||||
}
|
||||
|
||||
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestGeminiV1BetaHandler_ListModelsAntigravityFallback(t *testing.T) {
|
||||
expectedBehavior string
|
||||
}{
|
||||
{
|
||||
name: "有Gemini账户-调用ForwardAIStudioGET",
|
||||
name: "有Gemini账户-调用ForwardListModels",
|
||||
hasGeminiAccount: true,
|
||||
hasAntigravity: false,
|
||||
expectedBehavior: "forward_to_upstream",
|
||||
@@ -155,7 +155,7 @@ func TestGeminiV1BetaHandler_GetModelAntigravityFallback(t *testing.T) {
|
||||
expectedBehavior string
|
||||
}{
|
||||
{
|
||||
name: "有Gemini账户-调用ForwardAIStudioGET",
|
||||
name: "有Gemini账户-调用ForwardGetModel",
|
||||
hasGeminiAccount: true,
|
||||
hasAntigravity: false,
|
||||
expectedBehavior: "forward_to_upstream",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type geminiVertexModelsTokenCache struct{}
|
||||
|
||||
func (geminiVertexModelsTokenCache) GetAccessToken(context.Context, string) (string, error) {
|
||||
return "ya29.vertex-token", nil
|
||||
}
|
||||
|
||||
func (geminiVertexModelsTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (geminiVertexModelsTokenCache) DeleteAccessToken(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (geminiVertexModelsTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (geminiVertexModelsTokenCache) ReleaseRefreshLock(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 分组里只有 Agent Platform (Vertex) 服务账号时,模型列表与模型详情接口返回 Vertex 上的 Gemini 模型。
|
||||
func TestGeminiV1BetaModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) {
|
||||
groupID := int64(8)
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {{
|
||||
ID: 18,
|
||||
Platform: service.PlatformGemini,
|
||||
Type: service.AccountTypeServiceAccount,
|
||||
Credentials: map[string]any{
|
||||
"location": "global",
|
||||
"service_account_json": map[string]any{
|
||||
"type": "service_account",
|
||||
"project_id": "proj",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
|
||||
"client_email": "svc@proj.iam.gserviceaccount.com",
|
||||
},
|
||||
},
|
||||
}}}}}
|
||||
upstream := &geminiMixedModelsUpstream{status: http.StatusOK, body: `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`}
|
||||
tokenProvider := service.NewGeminiTokenProvider(nil, geminiVertexModelsTokenCache{}, nil)
|
||||
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, tokenProvider, nil, upstream, nil, &config.Config{})}
|
||||
apiKey := &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}}
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
|
||||
h.GeminiV1BetaListModels(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.JSONEq(t, `{"models":[{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}]}`, rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-3.8-flash", nil)
|
||||
c.Params = gin.Params{{Key: "model", Value: "gemini-3.8-flash"}}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
|
||||
h.GeminiV1BetaGetModel(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.JSONEq(t, `{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}`, rec.Body.String())
|
||||
})
|
||||
}
|
||||
@@ -49,6 +49,18 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "grok-voice-latest"
|
||||
}
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: model, Kind: service.InflightEstimateAudio, AudioMode: "realtime", AudioUnits: 1})
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
// Keep the HTTP response uncommitted while selecting and probing an account.
|
||||
// Realtime is not an HTTP streaming response; using reqStream=true here would
|
||||
// let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
|
||||
@@ -212,6 +224,18 @@ func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, grokVoiceInflightEstimate(endpoint, body))
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
contentType = "application/json"
|
||||
|
||||
@@ -173,6 +173,18 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, grokMediaInflightEstimate(endpoint, routingModel, requestInfo, body))
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
sessionSeed := body
|
||||
if len(sessionSeed) == 0 && strings.TrimSpace(requestID) != "" {
|
||||
sessionSeed = []byte(requestID)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToLocalizedModelPlazaGroups(t *testing.T) {
|
||||
visible := []service.PlazaGroup{
|
||||
{
|
||||
ID: 1, Name: "标准", Description: "号池", Platform: "anthropic",
|
||||
I18n: service.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}},
|
||||
},
|
||||
{ID: 2, Name: "无译文", Description: "原描述", Platform: "openai"},
|
||||
}
|
||||
|
||||
english := toLocalizedModelPlazaGroups(visible, nil, "en")
|
||||
require.Equal(t, "Standard", english[0].Name)
|
||||
require.Equal(t, "Pro + Max pool", english[0].Description)
|
||||
require.Equal(t, "无译文", english[1].Name)
|
||||
require.Equal(t, "原描述", english[1].Description)
|
||||
|
||||
chinese := toLocalizedModelPlazaGroups(visible, nil, "zh")
|
||||
require.Equal(t, "标准", chinese[0].Name)
|
||||
require.Equal(t, "号池", chinese[0].Description)
|
||||
|
||||
// service 层对象不被改写,同一批分组可继续按别的语言输出
|
||||
require.Equal(t, "标准", visible[0].Name)
|
||||
|
||||
raw, err := json.Marshal(english[0])
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.NotContains(t, decoded, "i18n")
|
||||
}
|
||||
|
||||
func TestLocalizeUserAvailableGroups(t *testing.T) {
|
||||
refs := []service.AvailableGroupRef{
|
||||
{ID: 1, Name: "标准", Platform: "anthropic", I18n: service.GroupI18n{"en": {Name: "Standard"}}},
|
||||
{ID: 2, Name: "无译文", Platform: "anthropic"},
|
||||
}
|
||||
allowed := map[int64]struct{}{1: {}, 2: {}}
|
||||
|
||||
english := filterUserVisibleGroups(refs, allowed)
|
||||
localizeUserAvailableGroups(english, "en")
|
||||
require.Equal(t, "Standard", english[0].Name)
|
||||
require.Equal(t, "无译文", english[1].Name)
|
||||
|
||||
chinese := filterUserVisibleGroups(refs, allowed)
|
||||
localizeUserAvailableGroups(chinese, "zh")
|
||||
require.Equal(t, "标准", chinese[0].Name)
|
||||
|
||||
raw, err := json.Marshal(english[0])
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.NotContains(t, decoded, "i18n")
|
||||
}
|
||||
|
||||
// 用户侧 handler 输出带分组的 DTO 时必须经过 Localized,否则分组名称与描述会
|
||||
// 停留在原字段,并把译文表原样带给用户。管理端(handler/admin)不在此列。
|
||||
func TestUserFacingGroupDTOsAreLocalized(t *testing.T) {
|
||||
mapperCall := regexp.MustCompile(`dto\.(APIKey|UserSubscription|RedeemCode|UsageLog|Group)FromService[A-Za-z]*\(`)
|
||||
|
||||
files, err := filepath.Glob("*.go")
|
||||
require.NoError(t, err)
|
||||
|
||||
checked := 0
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
continue
|
||||
}
|
||||
source, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
for i, line := range strings.Split(string(source), "\n") {
|
||||
if !mapperCall.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
require.Containsf(t, line, ".Localized(",
|
||||
"%s:%d outputs a group DTO without Localized(Accept-Language)", file, i+1)
|
||||
}
|
||||
}
|
||||
require.NotZero(t, checked, "expected to find user-facing group DTO call sites")
|
||||
}
|
||||
@@ -151,10 +151,10 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
|
||||
visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups)
|
||||
|
||||
out := make([]modelPlazaGroup, 0, len(visible))
|
||||
for i := range visible {
|
||||
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
|
||||
}
|
||||
// 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。
|
||||
// 追加而非覆盖:CORS 中间件已为具体来源写入 Vary: Origin。
|
||||
c.Writer.Header().Add("Vary", "Accept-Language")
|
||||
out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language"))
|
||||
response.Success(c, modelPlazaResponse{
|
||||
Description: rt.Description,
|
||||
Groups: out,
|
||||
@@ -185,6 +185,17 @@ func filterPlazaVisibleGroups(
|
||||
return visible
|
||||
}
|
||||
|
||||
// toLocalizedModelPlazaGroups 转换可见分组,并把名称与描述换成请求首选语言的版本。
|
||||
func toLocalizedModelPlazaGroups(visible []service.PlazaGroup, userRates map[int64]float64, acceptLanguage string) []modelPlazaGroup {
|
||||
out := make([]modelPlazaGroup, 0, len(visible))
|
||||
for i := range visible {
|
||||
group := toModelPlazaGroupDTO(&visible[i], userRates)
|
||||
group.Name, group.Description = visible[i].I18n.Localize(acceptLanguage, group.Name, group.Description)
|
||||
out = append(out, group)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
|
||||
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
|
||||
models := make([]modelPlazaModel, 0, len(g.Models))
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -262,3 +265,43 @@ func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
|
||||
require.Equal(t, int64(42), visible[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
type plazaVarySettingRepo struct{ service.SettingRepository }
|
||||
|
||||
func (plazaVarySettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return map[string]string{service.SettingKeyModelPlazaEnabled: "true"}, nil
|
||||
}
|
||||
|
||||
type plazaVaryChannelRepo struct{ service.ChannelRepository }
|
||||
|
||||
func (plazaVaryChannelRepo) ListAll(context.Context) ([]service.Channel, error) { return nil, nil }
|
||||
|
||||
type plazaVaryGroupRepo struct{ service.GroupRepository }
|
||||
|
||||
func (plazaVaryGroupRepo) ListActive(context.Context) ([]service.Group, error) { return nil, nil }
|
||||
|
||||
func TestModelPlazaHandler_VaryKeepsCORSOrigin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewModelPlazaHandler(
|
||||
service.NewModelPlazaService(plazaVaryChannelRepo{}, plazaVaryGroupRepo{}, nil, nil, nil),
|
||||
nil,
|
||||
service.NewSettingService(plazaVarySettingRepo{}, &config.Config{}),
|
||||
)
|
||||
r := gin.New()
|
||||
r.Use(middleware.CORS(config.CORSConfig{AllowedOrigins: []string{"https://a.example", "https://b.example"}}))
|
||||
r.GET("/api/v1/model-plaza", h.Get)
|
||||
|
||||
for _, origin := range []string{"https://a.example", "https://b.example"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin"))
|
||||
// 共享缓存需同时按来源与语言区分,否则 A 来源的响应可能被复用给 B。
|
||||
require.Equal(t, []string{"Origin", "Accept-Language"}, w.Header().Values("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,18 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: requestedModel, BodyBytes: len(body), MaxTokens: requestMaxOutputTokens(body), Kind: service.InflightEstimateToken, SearchCalls: 1})
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
|
||||
sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
|
||||
profitVetoCount := 0
|
||||
|
||||
@@ -146,6 +146,19 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if err != nil {
|
||||
reqLog.Info("openai_chat_completions.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.handleStreamingAwareError(c, status, code, message, streamStarted)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
|
||||
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
|
||||
|
||||
|
||||
@@ -1001,3 +1001,32 @@ func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) {
|
||||
require.Equal(t, http.StatusNotModified, second.Code, second.Body.String())
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
}
|
||||
|
||||
func TestCodexModelsDiscoveryProjectsWildcardMappingWithoutAllowlist(t *testing.T) {
|
||||
for _, mode := range []string{"ordinary", "pinned", "fallback"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
account := newPinnedCodexAccount(1, service.StatusActive, true, false)
|
||||
account.Credentials["model_mapping"] = map[string]any{"gpt-*": "gpt-5.4"}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"models":[{"slug":"gpt-5.4"},{"slug":"blocked"},{"slug":"codex-auto-review"},{"slug":"gpt-image-2"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler([]service.Account{account}, upstream, 3)
|
||||
group := &service.Group{ID: 84, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Models: []string{"blocked"}},
|
||||
}
|
||||
if mode != "ordinary" {
|
||||
group.CodexModelsManifestConfig = service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}
|
||||
}
|
||||
if mode == "fallback" {
|
||||
group.CodexModelsManifestConfig.AccountIDs = []int64{999}
|
||||
group.CodexModelsManifestConfig.FallbackToScheduler = true
|
||||
}
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
require.Equal(t, []string{"gpt-5.4"}, codexHandlerManifestSlugs(t, first))
|
||||
require.NotEmpty(t, first.Header().Get("ETag"))
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, first.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusNotModified, second.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,18 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: reqModel, BodyBytes: len(body), MaxTokens: 1, Kind: service.InflightEstimateToken})
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
@@ -258,13 +259,21 @@ func usageRecordContext(parent context.Context, base context.Context) context.Co
|
||||
return base
|
||||
}
|
||||
|
||||
func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) service.UsageRecordTask {
|
||||
// wrapUsageRecordTaskContext 包装计费任务:复制请求级 context 值,并接管请求的在途余额预留引用。
|
||||
// 返回的 abandon 在任务未被执行(被丢弃)时必须调用以归还预留引用;任务执行结束时(含 panic)
|
||||
// 自动归还,此时余额缓存已在计费路径中同步扣减。
|
||||
func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecordTask) (service.UsageRecordTask, func()) {
|
||||
if task == nil {
|
||||
return nil
|
||||
return nil, func() {}
|
||||
}
|
||||
done := func() {}
|
||||
if parent != nil {
|
||||
done = service.InflightReservationFromContext(parent).Acquire()
|
||||
}
|
||||
return func(ctx context.Context) {
|
||||
defer done()
|
||||
task(usageRecordContext(parent, ctx))
|
||||
}
|
||||
}, done
|
||||
}
|
||||
|
||||
func openAICompatibleRequestPlatform(ctx context.Context, apiKey *service.APIKey) string {
|
||||
@@ -322,9 +331,9 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
||||
|
||||
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
|
||||
return compositeTargetPlatformAllowed(c, apiKey, model,
|
||||
service.PlatformOpenAI, service.PlatformGrok,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
|
||||
service.PlatformMiniMax, service.PlatformOpenCodeGo)
|
||||
domain.PlatformIDsWhere(func(spec domain.PlatformSpec) bool {
|
||||
return spec.Gateway == domain.PlatformGatewayOpenAI
|
||||
})...)
|
||||
}
|
||||
|
||||
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
|
||||
@@ -607,6 +616,19 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留:防止并发请求在预检时看到同一份余额而集体透支。
|
||||
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if err != nil {
|
||||
reqLog.Info("openai.inflight_reservation_rejected", zap.Error(err))
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.handleStreamingAwareError(c, status, code, message, streamStarted)
|
||||
return
|
||||
}
|
||||
defer inflightRelease()
|
||||
|
||||
// Generate session hash (header first; fallback to prompt_cache_key)
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, sessionHashBody)
|
||||
if h.rejectIfCyberSessionBlocked(c, apiKey, sessionHashBody, reqModel, cyberBlockFormatResponses) {
|
||||
@@ -1243,6 +1265,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, body))
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.anthropicStreamingAwareError(c, status, code, message, streamStarted)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
|
||||
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
|
||||
sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(c, sessionHash, promptCacheKey, reqModel, body)
|
||||
@@ -2092,6 +2126,101 @@ func (p *openAIWSTurnPricing) currentOr(fallback time.Time) time.Time {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// openAIWSTurnAPIKeyLookup 是后续 turn 重取 API Key 认证快照的入口
|
||||
// (APIKeyService.GetByKey:经 L1/L2 认证缓存,未命中才回源)。
|
||||
type openAIWSTurnAPIKeyLookup interface {
|
||||
GetByKey(ctx context.Context, key string) (*service.APIKey, error)
|
||||
}
|
||||
|
||||
// openAIWSTurnBillingAPIKeys 按 turn 号保存每个 turn 计费用的 API Key 快照。
|
||||
//
|
||||
// API Key 认证快照只在建连时取一次。若连接内所有 turn 共用它,分组定价(倍率、
|
||||
// 高峰、图片定价、利润门参数)会停留在建连时刻:管理员调价后,已打开的长连接
|
||||
// 在客户端重连前一直按旧价计费、按旧售价过利润门,而 HTTP 请求每次都经认证缓存
|
||||
// 取快照,缓存失效后即生效。
|
||||
//
|
||||
// 因此后续 turn 在 BeforeTurn(与 HTTP 请求进入认证中间件同位)经同一认证缓存
|
||||
// 重取快照,只采用其中的分组,作为该 turn 利润门准入与用量计费共同的分组;用户、
|
||||
// Key 限额、订阅仍沿用建连快照。只在同一把 Key、同一分组且平台与订阅类型未变时
|
||||
// 采用:Key 换组或分组改平台/订阅类型不是调价,该连接按建连分组调度,继续按建连
|
||||
// 快照计费;重取失败同样保留建连快照,不断连。
|
||||
//
|
||||
// 首轮沿用刚经认证中间件取得的建连快照;没有经过 BeforeTurn 的 turn 回退建连
|
||||
// 快照。只保留当前与上一个 turn:下一 turn 的 BeforeTurn 先于上一 turn 的
|
||||
// AfterTurn 执行时,上一 turn 仍取到自己的快照。
|
||||
type openAIWSTurnBillingAPIKeys struct {
|
||||
mu sync.Mutex
|
||||
keys map[int]*service.APIKey
|
||||
}
|
||||
|
||||
// begin 在 BeforeTurn 重装利润门前调用:后续 turn 重取计费分组并按 turn 记下,
|
||||
// 返回换入该分组的上下文供本 turn 的利润门使用。
|
||||
func (k *openAIWSTurnBillingAPIKeys) begin(ctx context.Context, apiKeyService *service.APIKeyService, turn int, conn *service.APIKey) context.Context {
|
||||
turnKey := conn
|
||||
if turn > 1 && apiKeyService != nil {
|
||||
turnKey = refreshOpenAIWSTurnBillingAPIKey(ctx, apiKeyService, conn)
|
||||
}
|
||||
k.set(turn, turnKey)
|
||||
return withOpenAIWSTurnBillingGroup(ctx, turnKey)
|
||||
}
|
||||
|
||||
func (k *openAIWSTurnBillingAPIKeys) set(turn int, key *service.APIKey) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.keys == nil {
|
||||
k.keys = make(map[int]*service.APIKey, 2)
|
||||
}
|
||||
for t := range k.keys {
|
||||
if t < turn-1 {
|
||||
delete(k.keys, t)
|
||||
}
|
||||
}
|
||||
k.keys[turn] = key
|
||||
}
|
||||
|
||||
func (k *openAIWSTurnBillingAPIKeys) forTurn(turn int, conn *service.APIKey) *service.APIKey {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if key := k.keys[turn]; key != nil {
|
||||
return key
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// refreshOpenAIWSTurnBillingAPIKey 返回本 turn 计费用的 API Key:分组取当前认证
|
||||
// 快照,其余字段与建连快照共享。不满足采用条件时原样返回建连快照。
|
||||
func refreshOpenAIWSTurnBillingAPIKey(ctx context.Context, lookup openAIWSTurnAPIKeyLookup, conn *service.APIKey) *service.APIKey {
|
||||
if lookup == nil || conn == nil || conn.Key == "" || conn.GroupID == nil || conn.Group == nil {
|
||||
return conn
|
||||
}
|
||||
latest, err := lookup.GetByKey(ctx, conn.Key)
|
||||
if err != nil || latest == nil || latest.ID != conn.ID || latest.GroupID == nil || *latest.GroupID != *conn.GroupID {
|
||||
return conn
|
||||
}
|
||||
group := latest.Group
|
||||
if group == nil || group.ID != conn.Group.ID ||
|
||||
group.Platform != conn.Group.Platform ||
|
||||
group.SubscriptionType != conn.Group.SubscriptionType {
|
||||
return conn
|
||||
}
|
||||
turnKey := *conn
|
||||
turnKey.Group = group
|
||||
return &turnKey
|
||||
}
|
||||
|
||||
// withOpenAIWSTurnBillingGroup 把本 turn 的计费分组换进认证分组上下文,使利润门
|
||||
// 的售价与本 turn 计费同源。上下文里没有同 ID 的认证分组时不改动。
|
||||
func withOpenAIWSTurnBillingGroup(ctx context.Context, turnKey *service.APIKey) context.Context {
|
||||
if turnKey == nil || turnKey.Group == nil {
|
||||
return ctx
|
||||
}
|
||||
current, ok := ctx.Value(ctxkey.Group).(*service.Group)
|
||||
if !ok || current == nil || current == turnKey.Group || current.ID != turnKey.Group.ID {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, ctxkey.Group, turnKey.Group)
|
||||
}
|
||||
|
||||
// recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号
|
||||
// 加入本请求排除集并递增否决计数。返回 false 表示否决次数已达
|
||||
// maxProfitVetoAttempts,调用方必须停止重选并按「无可用账号」终止。
|
||||
@@ -2411,6 +2540,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route")
|
||||
return
|
||||
}
|
||||
if decision.OAuthAmbiguous() {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.AmbiguousMessage())
|
||||
return
|
||||
}
|
||||
if decision.Matched {
|
||||
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
|
||||
wsRouteModel = decision.UpstreamModel
|
||||
@@ -2541,6 +2675,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(会话级):按首帧估算一次,会话期间续期;每轮计费任务接管引用,
|
||||
// 会话结束且所有轮次扣减落地后释放。
|
||||
inflightCtx, inflightDone, inflightErr := reserveInflightBalanceCtx(ctx, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(reqModel, firstMessage))
|
||||
if inflightErr != nil {
|
||||
reqLog.Info("openai.websocket_inflight_reservation_rejected", zap.Error(inflightErr))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
ctx = inflightCtx
|
||||
|
||||
// A WebSocket may outlive a key's remaining spending window. Recheck
|
||||
// after acquiring turn slots, including the first account-selection wait.
|
||||
// Restrict this extra check to the opt-in mode so standard-mode RPM checks
|
||||
@@ -2812,6 +2957,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
|
||||
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
// 后续 turn 的计费分组同样在 BeforeTurn 经认证缓存重取,使分组调价对
|
||||
// 已打开的连接生效。
|
||||
var turnBillingAPIKeys openAIWSTurnBillingAPIKeys
|
||||
// Passthrough ingress does not invoke BeforeTurn for the first frame.
|
||||
if err := checkSimpleModeTurnBilling(); err != nil {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
|
||||
@@ -2899,7 +3047,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核
|
||||
// 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号,
|
||||
// 无法中途换号)。本 turn 的准入与计费共用同一 pricingAt。
|
||||
turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(ctx, apiKey.GroupID)
|
||||
turnBillingCtx := turnBillingAPIKeys.begin(ctx, h.apiKeyService, turn, apiKey)
|
||||
turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(turnBillingCtx, apiKey.GroupID)
|
||||
if _, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(turnCtx, account); vetoed {
|
||||
reqLog.Info("openai.websocket_turn_profit_vetoed",
|
||||
zap.Int("turn", turn),
|
||||
@@ -3015,11 +3164,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
turnRecordPricingAt := turnPricing.currentOr(turnStart)
|
||||
turnBillingAPIKey := turnBillingAPIKeys.forTurn(turn, apiKey)
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
|
||||
Result: result,
|
||||
APIKey: apiKey,
|
||||
APIKey: turnBillingAPIKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
@@ -3290,9 +3440,12 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
task, abandon := wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
|
||||
if mode.Dropped() {
|
||||
abandon()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
|
||||
@@ -3329,7 +3482,7 @@ func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Con
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
task, _ = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
@@ -1948,6 +1949,12 @@ type openAIResponsesWSUsageLogCase struct {
|
||||
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
|
||||
// group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。
|
||||
group *service.Group
|
||||
// apiKeyService 非 nil 时模拟 API Key 认证中间件:连接认证快照经它按
|
||||
// apiKeyCredential 取得,并把其分组放入请求 ctx;handler 也使用它。
|
||||
apiKeyService *service.APIKeyService
|
||||
apiKeyCredential string
|
||||
// accountRateMultiplier 覆盖账号倍率(利润门测试用)。
|
||||
accountRateMultiplier *float64
|
||||
// firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。
|
||||
firstFrameCloseExpected bool
|
||||
// secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。
|
||||
@@ -2962,6 +2969,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
if tc.accountPlatform != "" {
|
||||
account.Platform = tc.accountPlatform
|
||||
}
|
||||
account.RateMultiplier = tc.accountRateMultiplier
|
||||
if strings.TrimSpace(tc.ingressMode) != "" {
|
||||
account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode
|
||||
}
|
||||
@@ -3050,6 +3058,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 1701, Status: service.StatusActive},
|
||||
}
|
||||
if tc.apiKeyService != nil {
|
||||
h.apiKeyService = tc.apiKeyService
|
||||
authKey, err := tc.apiKeyService.GetByKey(context.Background(), tc.apiKeyCredential)
|
||||
require.NoError(t, err)
|
||||
apiKey = authKey
|
||||
}
|
||||
if tc.simpleModeRejectAtRead > 0 {
|
||||
apiKey.RateLimit5h = 1
|
||||
}
|
||||
@@ -3060,6 +3074,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
|
||||
if tc.apiKeyService != nil && apiKey.Group != nil {
|
||||
c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), ctxkey.Group, apiKey.Group))
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/openai/v1/responses", h.ResponsesWebSocket)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 分组调价对已打开的 WebSocket 连接生效:同一条连接的两个 turn 之间由管理端
|
||||
// 改分组倍率(并按分组失效认证缓存),第二个 turn 必须按新倍率计费、按新售价
|
||||
// 过利润门;HTTP 请求每次经认证缓存取快照,本来就是这个行为。
|
||||
|
||||
const (
|
||||
wsGroupPricingGroupID = int64(4201) // 与 runOpenAIResponsesWebSocketUsageLogCase 的调度分组一致
|
||||
wsGroupPricingKey = "sk-ws-group-pricing"
|
||||
wsGroupPricingFirst = `{"type":"response.create","model":"gpt-5.6-sol","stream":false}`
|
||||
wsGroupPricingSecond = `{"type":"response.create","model":"gpt-5.6-sol","stream":false}`
|
||||
)
|
||||
|
||||
// wsGroupPricingAPIKeyRepoStub 只实现认证查询与按分组列 Key(认证缓存失效用)。
|
||||
type wsGroupPricingAPIKeyRepoStub struct {
|
||||
service.APIKeyRepository
|
||||
mu sync.Mutex
|
||||
apiKey service.APIKey
|
||||
group service.Group
|
||||
err error
|
||||
lookups int
|
||||
}
|
||||
|
||||
func newWSGroupPricingAPIKeyRepoStub(rate float64) *wsGroupPricingAPIKeyRepoStub {
|
||||
groupID := wsGroupPricingGroupID
|
||||
return &wsGroupPricingAPIKeyRepoStub{
|
||||
apiKey: service.APIKey{
|
||||
ID: 1801,
|
||||
UserID: 1701,
|
||||
Key: wsGroupPricingKey,
|
||||
Status: service.StatusActive,
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 1701, Status: service.StatusActive, Concurrency: 1},
|
||||
},
|
||||
group: service.Group{
|
||||
ID: groupID,
|
||||
Name: "ws-group-pricing",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
RateMultiplier: rate,
|
||||
Hydrated: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *wsGroupPricingAPIKeyRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lookups++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if key != s.apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
apiKey := s.apiKey
|
||||
user := *s.apiKey.User
|
||||
apiKey.User = &user
|
||||
group := s.group
|
||||
apiKey.Group = &group
|
||||
return &apiKey, nil
|
||||
}
|
||||
|
||||
func (s *wsGroupPricingAPIKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
|
||||
return []string{s.apiKey.Key}, nil
|
||||
}
|
||||
|
||||
// adminUpdate 模拟管理端改 Key/分组后按分组失效认证缓存(与 admin 分组更新同一入口)。
|
||||
func (s *wsGroupPricingAPIKeyRepoStub) adminUpdate(apiKeyService *service.APIKeyService, fn func(apiKey *service.APIKey, group *service.Group)) error {
|
||||
s.mu.Lock()
|
||||
fn(&s.apiKey, &s.group)
|
||||
s.mu.Unlock()
|
||||
apiKeyService.InvalidateAuthCacheByGroupID(context.Background(), wsGroupPricingGroupID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *wsGroupPricingAPIKeyRepoStub) lookupCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lookups
|
||||
}
|
||||
|
||||
func newWSGroupPricingAPIKeyService(repo *wsGroupPricingAPIKeyRepoStub) *service.APIKeyService {
|
||||
return service.NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{})
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_GroupRateChangeAppliesToNextTurn(t *testing.T) {
|
||||
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeCtxPool} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
repo := newWSGroupPricingAPIKeyRepoStub(3.0)
|
||||
apiKeyService := newWSGroupPricingAPIKeyService(repo)
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: wsGroupPricingFirst,
|
||||
secondPayload: wsGroupPricingSecond,
|
||||
ingressMode: mode,
|
||||
apiKeyService: apiKeyService,
|
||||
apiKeyCredential: wsGroupPricingKey,
|
||||
afterFirstUpstreamRequest: func(*service.ChannelService) error {
|
||||
return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) { group.RateMultiplier = 0.3 })
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, got.logs, 2)
|
||||
require.InDelta(t, 3.0, got.logs[0].RateMultiplier, 1e-12, "turn 1 bills at the rate in force at connect")
|
||||
require.InDelta(t, 0.3, got.logs[1].RateMultiplier, 1e-12, "turn 2 must bill at the group rate in force when it starts")
|
||||
require.Greater(t, got.logs[0].ActualCost, 0.0)
|
||||
require.InDelta(t, got.logs[0].ActualCost/10, got.logs[1].ActualCost, 1e-15)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_GroupRateChangeReachesProfitGateOnNextTurn(t *testing.T) {
|
||||
repo := newWSGroupPricingAPIKeyRepoStub(3.0)
|
||||
repo.group.ProfitControlEnabled = true
|
||||
repo.group.ProfitMinMargin = 0.1
|
||||
apiKeyService := newWSGroupPricingAPIKeyService(repo)
|
||||
accountRate := 1.0
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: wsGroupPricingFirst,
|
||||
secondPayload: wsGroupPricingSecond,
|
||||
apiKeyService: apiKeyService,
|
||||
apiKeyCredential: wsGroupPricingKey,
|
||||
accountRateMultiplier: &accountRate,
|
||||
// 3.0 → 0.3:账号倍率 1.0 在新售价下已越线,第二个 turn 必须在转发上游前被拒。
|
||||
afterFirstUpstreamRequest: func(*service.ChannelService) error {
|
||||
return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) { group.RateMultiplier = 0.3 })
|
||||
},
|
||||
secondTurnCloseExpected: true,
|
||||
closeStatus: coderws.StatusTryAgainLater,
|
||||
closeReason: "no longer eligible for this connection",
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_KeyMovedToAnotherGroupKeepsConnectionGroup(t *testing.T) {
|
||||
repo := newWSGroupPricingAPIKeyRepoStub(3.0)
|
||||
apiKeyService := newWSGroupPricingAPIKeyService(repo)
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: wsGroupPricingFirst,
|
||||
secondPayload: wsGroupPricingSecond,
|
||||
apiKeyService: apiKeyService,
|
||||
apiKeyCredential: wsGroupPricingKey,
|
||||
afterFirstUpstreamRequest: func(*service.ChannelService) error {
|
||||
return repo.adminUpdate(apiKeyService, func(apiKey *service.APIKey, group *service.Group) {
|
||||
other := wsGroupPricingGroupID + 1
|
||||
apiKey.GroupID = &other
|
||||
group.ID = other
|
||||
group.RateMultiplier = 0.3
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, got.logs, 2)
|
||||
require.InDelta(t, 3.0, got.logs[1].RateMultiplier, 1e-12,
|
||||
"a key moved to another group keeps the connection's group: the connection was scheduled from it")
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_FailedKeyRefreshKeepsConnectionGroup(t *testing.T) {
|
||||
repo := newWSGroupPricingAPIKeyRepoStub(3.0)
|
||||
apiKeyService := newWSGroupPricingAPIKeyService(repo)
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: wsGroupPricingFirst,
|
||||
secondPayload: wsGroupPricingSecond,
|
||||
apiKeyService: apiKeyService,
|
||||
apiKeyCredential: wsGroupPricingKey,
|
||||
afterFirstUpstreamRequest: func(*service.ChannelService) error {
|
||||
return repo.adminUpdate(apiKeyService, func(_ *service.APIKey, group *service.Group) {
|
||||
group.RateMultiplier = 0.3
|
||||
repo.err = errors.New("auth store unavailable")
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, got.logs, 2)
|
||||
require.InDelta(t, 3.0, got.logs[1].RateMultiplier, 1e-12, "a failed refresh keeps the connection snapshot")
|
||||
require.GreaterOrEqual(t, repo.lookupCount(), 2, "the second turn must have attempted a refresh")
|
||||
}
|
||||
|
||||
type wsTurnAPIKeyLookupFunc func(ctx context.Context, key string) (*service.APIKey, error)
|
||||
|
||||
func (f wsTurnAPIKeyLookupFunc) GetByKey(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return f(ctx, key)
|
||||
}
|
||||
|
||||
func TestRefreshOpenAIWSTurnBillingAPIKey(t *testing.T) {
|
||||
groupID := int64(77)
|
||||
conn := &service.APIKey{
|
||||
ID: 5,
|
||||
Key: "sk-conn",
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 9},
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, SubscriptionType: service.SubscriptionTypeStandard, RateMultiplier: 3},
|
||||
}
|
||||
latestWith := func(mutate func(k *service.APIKey)) *service.APIKey {
|
||||
gid := groupID
|
||||
k := &service.APIKey{
|
||||
ID: 5,
|
||||
Key: "sk-conn",
|
||||
GroupID: &gid,
|
||||
User: &service.User{ID: 9, Balance: 123},
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, SubscriptionType: service.SubscriptionTypeStandard, RateMultiplier: 0.3},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(k)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
t.Run("same group adopts the latest group and nothing else", func(t *testing.T) {
|
||||
got := refreshOpenAIWSTurnBillingAPIKey(context.Background(), wsTurnAPIKeyLookupFunc(func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
require.Equal(t, "sk-conn", key)
|
||||
return latestWith(nil), nil
|
||||
}), conn)
|
||||
require.NotSame(t, conn, got)
|
||||
require.InDelta(t, 0.3, got.Group.RateMultiplier, 1e-12)
|
||||
require.Same(t, conn.User, got.User, "only the group snapshot is refreshed")
|
||||
require.Equal(t, conn.ID, got.ID)
|
||||
require.InDelta(t, 3.0, conn.Group.RateMultiplier, 1e-12, "the connection snapshot is never mutated")
|
||||
})
|
||||
|
||||
keep := map[string]wsTurnAPIKeyLookupFunc{
|
||||
"lookup error": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
"not found": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
},
|
||||
"nil key": func(ctx context.Context, key string) (*service.APIKey, error) { return nil, nil },
|
||||
"different key id": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return latestWith(func(k *service.APIKey) { k.ID = 6 }), nil
|
||||
},
|
||||
"moved group": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return latestWith(func(k *service.APIKey) { other := int64(78); k.GroupID = &other; k.Group.ID = other }), nil
|
||||
},
|
||||
"no group": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return latestWith(func(k *service.APIKey) { k.Group = nil }), nil
|
||||
},
|
||||
"platform changed": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return latestWith(func(k *service.APIKey) { k.Group.Platform = service.PlatformAnthropic }), nil
|
||||
},
|
||||
"billing type changed": func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return latestWith(func(k *service.APIKey) { k.Group.SubscriptionType = service.SubscriptionTypeSubscription }), nil
|
||||
},
|
||||
}
|
||||
for name, lookup := range keep {
|
||||
t.Run(name+" keeps the connection snapshot", func(t *testing.T) {
|
||||
require.Same(t, conn, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, conn))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no credential or no group skips the lookup", func(t *testing.T) {
|
||||
called := false
|
||||
lookup := wsTurnAPIKeyLookupFunc(func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
called = true
|
||||
return latestWith(nil), nil
|
||||
})
|
||||
noKey := *conn
|
||||
noKey.Key = ""
|
||||
require.Same(t, &noKey, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, &noKey))
|
||||
noGroup := *conn
|
||||
noGroup.Group = nil
|
||||
require.Same(t, &noGroup, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, &noGroup))
|
||||
require.Nil(t, refreshOpenAIWSTurnBillingAPIKey(context.Background(), lookup, nil))
|
||||
require.Same(t, conn, refreshOpenAIWSTurnBillingAPIKey(context.Background(), nil, conn))
|
||||
require.False(t, called)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIWSTurnBillingAPIKeysKeepPreviousTurnUntilItIsRecorded(t *testing.T) {
|
||||
conn := &service.APIKey{ID: 1}
|
||||
turn2 := &service.APIKey{ID: 2}
|
||||
turn3 := &service.APIKey{ID: 3}
|
||||
var keys openAIWSTurnBillingAPIKeys
|
||||
require.Same(t, conn, keys.forTurn(1, conn), "a turn without BeforeTurn bills with the connection snapshot")
|
||||
keys.set(2, turn2)
|
||||
keys.set(3, turn3) // turn 3 starts before turn 2's usage is submitted
|
||||
require.Same(t, turn2, keys.forTurn(2, conn))
|
||||
require.Same(t, turn3, keys.forTurn(3, conn))
|
||||
keys.set(4, conn)
|
||||
require.Same(t, conn, keys.forTurn(2, conn), "only the current and previous turn are kept")
|
||||
require.Len(t, keys.keys, 2)
|
||||
}
|
||||
@@ -143,6 +143,18 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。
|
||||
inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: routingModel, BodyBytes: len(body), Kind: service.InflightEstimateImage, Units: parsed.N})
|
||||
if inflightErr != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(inflightErr)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.handleStreamingAwareError(c, status, code, message, streamStarted)
|
||||
return
|
||||
}
|
||||
defer inflightDone()
|
||||
|
||||
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body)
|
||||
requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context()))
|
||||
|
||||
|
||||
@@ -1183,6 +1183,14 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Google 格式错误体没有 type 字段:按网关回写时登记的错误类型补齐。
|
||||
if errType, code := service.OpsLocalErrorType(c); errType != "" && !parsed.StreamFailure &&
|
||||
(parsed.ErrorType == "" || parsed.ErrorType == "api_error") {
|
||||
parsed.ErrorType = errType
|
||||
if code != "" {
|
||||
parsed.Code = code
|
||||
}
|
||||
}
|
||||
normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code)
|
||||
|
||||
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, parsed.Message, parsed.Code, status)
|
||||
@@ -2214,6 +2222,11 @@ func classifyOpsSeverity(errType string, status int) string {
|
||||
|
||||
func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status int) (phase string, isBusinessLimited bool, errorOwner string, errorSource string) {
|
||||
phase = classifyOpsPhase(errType, message, code)
|
||||
if service.IsOpsRequestScopedError(c) {
|
||||
// 失败由请求内容决定:只按错误类型分类,上游错误上下文不参与归因。
|
||||
isBusinessLimited = classifyOpsIsBusinessLimited(errType, phase, code, status, message)
|
||||
return phase, isBusinessLimited, classifyOpsErrorOwner(phase, message), classifyOpsErrorSource(phase, message)
|
||||
}
|
||||
routingCapacityLimited := isOpsRoutingCapacityLimited(c)
|
||||
clientBusinessLimited := service.HasOpsClientBusinessLimited(c)
|
||||
localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// recordGoogleFormatOpsError 让 handle 以 Google 错误格式回写错误,返回 ops 中间件记录的错误日志。
|
||||
func recordGoogleFormatOpsError(t *testing.T, handle gin.HandlerFunc) *service.OpsInsertErrorLogInput {
|
||||
t.Helper()
|
||||
setupOpsErrorLogTestQueue(t, 1)
|
||||
gin.SetMode(gin.TestMode)
|
||||
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(OpsErrorLoggerMiddleware(ops))
|
||||
router.POST("/v1beta/cachedContents", handle)
|
||||
|
||||
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1beta/cachedContents", nil))
|
||||
|
||||
require.Equal(t, int64(1), OpsErrorLogQueueLength())
|
||||
return (<-opsErrorLogQueue).entry
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GoogleClientErrorsClassifiedAsClient(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
errType string
|
||||
phase string
|
||||
}{
|
||||
{status: http.StatusBadRequest, errType: "invalid_request_error", phase: "request"},
|
||||
{status: http.StatusRequestEntityTooLarge, errType: "invalid_request_error", phase: "request"},
|
||||
{status: http.StatusUnauthorized, errType: "authentication_error", phase: "auth"},
|
||||
{status: http.StatusForbidden, errType: "permission_error", phase: "request"},
|
||||
{status: http.StatusNotFound, errType: "not_found_error", phase: "request"},
|
||||
} {
|
||||
t.Run(http.StatusText(tc.status), func(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
googleError(c, tc.status, "client request problem")
|
||||
})
|
||||
require.Equal(t, tc.status, entry.StatusCode)
|
||||
require.Equal(t, tc.errType, entry.ErrorType)
|
||||
require.Equal(t, tc.phase, entry.ErrorPhase)
|
||||
require.Equal(t, "client", entry.ErrorOwner)
|
||||
require.Equal(t, "client_request", entry.ErrorSource)
|
||||
require.Equal(t, "P3", entry.Severity)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GooglePlatformErrorStaysPlatform(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
googlePlatformError(c, http.StatusBadRequest, "Explicit context caching is not available for model m: storage pricing is not configured")
|
||||
})
|
||||
require.Equal(t, "api_error", entry.ErrorType)
|
||||
require.Equal(t, "internal", entry.ErrorPhase)
|
||||
require.Equal(t, "platform", entry.ErrorOwner)
|
||||
require.Equal(t, "gateway", entry.ErrorSource)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GoogleServerErrorsNotTypedByStatus(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
googleError(c, http.StatusInternalServerError, "Failed to persist cached content")
|
||||
})
|
||||
require.Equal(t, "api_error", entry.ErrorType)
|
||||
require.Equal(t, "platform", entry.ErrorOwner)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GoogleBillingErrorMatchesClaudeFormat(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
status, code, message, _ := billingErrorDetails(service.ErrInsufficientBalance)
|
||||
googleErrorWithType(c, status, code, "", message)
|
||||
})
|
||||
require.Equal(t, "billing_error", entry.ErrorType)
|
||||
require.Equal(t, "request", entry.ErrorPhase)
|
||||
require.Equal(t, "client", entry.ErrorOwner)
|
||||
require.True(t, entry.IsBusinessLimited)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GoogleConcurrencyErrorClassifiedAsRequest(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
googleConcurrencyError(c, &ConcurrencyError{SlotType: "user"}, "user")
|
||||
})
|
||||
require.Equal(t, http.StatusTooManyRequests, entry.StatusCode)
|
||||
require.Equal(t, "rate_limit_error", entry.ErrorType)
|
||||
require.Equal(t, "request", entry.ErrorPhase)
|
||||
require.Equal(t, "client", entry.ErrorOwner)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_GoogleErrorAfterUpstreamFailureKeepsUpstreamAttribution(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
service.SetOpsUpstreamError(c, http.StatusBadRequest, "upstream rejected", "")
|
||||
googleError(c, http.StatusBadRequest, "upstream rejected")
|
||||
})
|
||||
require.Equal(t, "upstream", entry.ErrorPhase)
|
||||
require.Equal(t, "provider", entry.ErrorOwner)
|
||||
require.Equal(t, "upstream_http", entry.ErrorSource)
|
||||
}
|
||||
|
||||
func TestOpsErrorLoggerMiddleware_RequestScopedUpstreamErrorClassifiedAsClient(t *testing.T) {
|
||||
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
|
||||
service.SetOpsUpstreamError(c, http.StatusBadRequest, "Invalid resource state for cache content 8079767076522164224.", "")
|
||||
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
|
||||
Platform: service.PlatformGemini, AccountID: 173, UpstreamStatusCode: http.StatusBadRequest, Kind: "http_error",
|
||||
Message: "Invalid resource state for cache content 8079767076522164224.",
|
||||
}})
|
||||
service.MarkOpsRequestScopedError(c, "permission_error")
|
||||
c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse))
|
||||
})
|
||||
require.Equal(t, http.StatusForbidden, entry.StatusCode)
|
||||
require.Equal(t, "permission_error", entry.ErrorType)
|
||||
require.Equal(t, "request", entry.ErrorPhase)
|
||||
require.Equal(t, "client", entry.ErrorOwner)
|
||||
require.Equal(t, "client_request", entry.ErrorSource)
|
||||
require.False(t, entry.IsBusinessLimited)
|
||||
require.NotNil(t, entry.UpstreamStatusCode, "上游原始错误保留为明细")
|
||||
require.Equal(t, http.StatusBadRequest, *entry.UpstreamStatusCode)
|
||||
}
|
||||
|
||||
func TestSetOpsLocalErrorTypeClearsRequestScopedMark(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
service.MarkOpsRequestScopedError(c, "permission_error")
|
||||
require.True(t, service.IsOpsRequestScopedError(c))
|
||||
|
||||
service.SetOpsLocalErrorType(c, "", "")
|
||||
require.False(t, service.IsOpsRequestScopedError(c))
|
||||
errType, code := service.OpsLocalErrorType(c)
|
||||
require.Empty(t, errType)
|
||||
require.Empty(t, code)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) {
|
||||
gi := groupInfo[p.GroupID]
|
||||
result = append(result, planWithPlatform{
|
||||
ID: int64(p.ID), GroupID: p.GroupID,
|
||||
GroupPlatform: gi.Platform, GroupName: gi.Name,
|
||||
GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name),
|
||||
RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled,
|
||||
PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
|
||||
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
|
||||
@@ -126,7 +126,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
|
||||
gi := groupInfo[p.GroupID]
|
||||
planList = append(planList, checkoutPlan{
|
||||
ID: int64(p.ID), GroupID: p.GroupID,
|
||||
GroupPlatform: gi.Platform, GroupName: gi.Name,
|
||||
GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name),
|
||||
RateMultiplier: gi.RateMultiplier,
|
||||
PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart,
|
||||
PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
|
||||
@@ -149,6 +149,9 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
|
||||
BalanceRechargeMultiplier: cfg.BalanceRechargeMultiplier,
|
||||
SubscriptionUSDToCNYRate: cfg.SubscriptionUSDToCNYRate,
|
||||
RechargeFeeRate: cfg.RechargeFeeRate,
|
||||
RechargeBonusTiers: cfg.RechargeBonusTiers,
|
||||
RechargeBonusMode: cfg.RechargeBonusMode,
|
||||
RechargeBonusNotice: cfg.RechargeBonusNotice,
|
||||
HelpText: cfg.HelpText,
|
||||
HelpImageURL: cfg.HelpImageURL,
|
||||
StripePublishableKey: cfg.StripePublishableKey,
|
||||
@@ -166,6 +169,9 @@ type checkoutInfoResponse struct {
|
||||
BalanceRechargeMultiplier float64 `json:"balance_recharge_multiplier"`
|
||||
SubscriptionUSDToCNYRate float64 `json:"subscription_usd_to_cny_rate"`
|
||||
RechargeFeeRate float64 `json:"recharge_fee_rate"`
|
||||
RechargeBonusTiers []service.RechargeBonusTier `json:"recharge_bonus_tiers"`
|
||||
RechargeBonusMode string `json:"recharge_bonus_mode"`
|
||||
RechargeBonusNotice string `json:"recharge_bonus_notice"`
|
||||
HelpText string `json:"help_text"`
|
||||
HelpImageURL string `json:"help_image_url"`
|
||||
StripePublishableKey string `json:"stripe_publishable_key"`
|
||||
@@ -482,6 +488,7 @@ type PublicOrderResult struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OrderType string `json:"order_type"`
|
||||
@@ -517,6 +524,7 @@ func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
|
||||
Amount: order.Amount,
|
||||
PayAmount: order.PayAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
BonusAmount: order.BonusAmount,
|
||||
Currency: service.PaymentOrderCurrency(order),
|
||||
PaymentType: order.PaymentType,
|
||||
OrderType: order.OrderType,
|
||||
@@ -625,6 +633,7 @@ type PaymentOrderResult struct {
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
BonusAmount float64 `json:"bonus_amount"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
@@ -663,6 +672,7 @@ func sanitizePaymentOrderForResponse(order *dbent.PaymentOrder) *PaymentOrderRes
|
||||
Amount: order.Amount,
|
||||
PayAmount: order.PayAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
BonusAmount: order.BonusAmount,
|
||||
Currency: service.PaymentOrderCurrency(order),
|
||||
PaymentType: order.PaymentType,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user