mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 04:22:44 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2779cd5f3 | ||
|
|
dbef64bb45 | ||
|
|
3ecfcc48ae | ||
|
|
35748d8c51 | ||
|
|
e2e375d694 | ||
|
|
1f5ee8123f | ||
|
|
393a8fe56a | ||
|
|
09729ba54c | ||
|
|
590efe29a5 | ||
|
|
813920607e | ||
|
|
605b026cc4 | ||
|
|
2d97207d41 | ||
|
|
d67a2cad2b | ||
|
|
2de6ccb071 | ||
|
|
a1af031969 | ||
|
|
0ddd58aaf9 | ||
|
|
0eb6e21aaa | ||
|
|
e5e94d1eb1 | ||
|
|
1fb942dd77 | ||
|
|
09c6c6d740 | ||
|
|
0408bdb34f | ||
|
|
ab978e615e | ||
|
|
8d36ce3d20 | ||
|
|
e2bb90a80d | ||
|
|
08828293b7 | ||
|
|
502097026f | ||
|
|
a3a227c858 | ||
|
|
51f2b0e3ee | ||
|
|
531ae04a0b | ||
|
|
e4329a04e8 | ||
|
|
f3138ceb43 | ||
|
|
1c5c42c183 | ||
|
|
3fc0336e1d | ||
|
|
807850769f | ||
|
|
8852194670 | ||
|
|
4226ff8e3a | ||
|
|
ff08694d79 | ||
|
|
7e43ebc792 | ||
|
|
4a50d05393 | ||
|
|
ba01199812 | ||
|
|
25d7797d83 | ||
|
|
d22f4d9b5b | ||
|
|
f552448fb2 | ||
|
|
62d57c02d8 | ||
|
|
ad5e2a85b7 | ||
|
|
90ee85f3ef | ||
|
|
72fada40f6 | ||
|
|
695665cbc8 | ||
|
|
776f3f0de7 | ||
|
|
06e03f467a | ||
|
|
7c43b7327d | ||
|
|
10cb2ca42c | ||
|
|
3db00d3fee | ||
|
|
e8cc1fe26b | ||
|
|
410ea8490c | ||
|
|
a8a8a58f65 | ||
|
|
0765d10c1d | ||
|
|
40b8f04a6a | ||
|
|
f59a6ed74c | ||
|
|
5e4da92dec | ||
|
|
56650d6aed | ||
|
|
2fe7df9b81 | ||
|
|
4f641208a0 | ||
|
|
df247b4364 | ||
|
|
134179085c | ||
|
|
eb2b8632de | ||
|
|
858f3e4a78 | ||
|
|
716fcc6f3d | ||
|
|
be6cd1250c | ||
|
|
34015a1791 | ||
|
|
a733e66330 | ||
|
|
960d1886f3 | ||
|
|
221581400b | ||
|
|
c352d99d92 | ||
|
|
2147da682b | ||
|
|
12e6e70775 | ||
|
|
3b5072187a | ||
|
|
529744c7c7 | ||
|
|
736824dd7a | ||
|
|
705da4f610 | ||
|
|
d515c3045c | ||
|
|
12f991dde8 | ||
|
|
4e4ce440b3 | ||
|
|
bdb5be1c42 | ||
|
|
0de768e8be | ||
|
|
d348193179 | ||
|
|
bac925624f | ||
|
|
651bb019d5 | ||
|
|
4de53b61b8 | ||
|
|
dba6cefb6b | ||
|
|
80560e48d2 | ||
|
|
ade5944201 | ||
|
|
44452d4a9d | ||
|
|
8e84071f21 | ||
|
|
788b1cebf1 | ||
|
|
9b4a7d5d47 | ||
|
|
d6d9f6ea4f | ||
|
|
c3a425837c | ||
|
|
986310d0bd | ||
|
|
2d218fbe61 | ||
|
|
9d1b44e6a0 | ||
|
|
601a1d1662 | ||
|
|
83ef20bf8b | ||
|
|
3170c110a0 | ||
|
|
08ea29428d | ||
|
|
e4a0e69424 | ||
|
|
156cabd260 | ||
|
|
febc5cbdd8 | ||
|
|
3e30862ddd | ||
|
|
9950f4a2e2 | ||
|
|
d5bc576b4f | ||
|
|
f041b5cf0a | ||
|
|
fc4089f292 | ||
|
|
e8e360c802 | ||
|
|
7b51271a84 | ||
|
|
bc6d9c47d2 | ||
|
|
430fd8f20a | ||
|
|
2bbdd47002 | ||
|
|
6a61b2daca | ||
|
|
3f605c3543 | ||
|
|
23796a1b34 | ||
|
|
5b5e41450c | ||
|
|
10798abe4f | ||
|
|
4344f6afb0 | ||
|
|
ab369c363f | ||
|
|
6460b9895c | ||
|
|
f7da6e2bc6 | ||
|
|
60ff61132d | ||
|
|
1acbb12c65 | ||
|
|
cf6aa1a5c3 | ||
|
|
40a59c9e86 | ||
|
|
65d744d0e1 | ||
|
|
2381b6c104 | ||
|
|
a38220ebdc | ||
|
|
2ceaa4783c | ||
|
|
9b86585909 | ||
|
|
42d1bf298d | ||
|
|
eb7933af4c | ||
|
|
4355861ef2 | ||
|
|
0c80d52573 | ||
|
|
3a3b962de3 | ||
|
|
b245ab5848 | ||
|
|
6a62ea1b1d | ||
|
|
6b25900403 | ||
|
|
30df3f68e4 | ||
|
|
1c61225b28 | ||
|
|
7cb8ece8d3 | ||
|
|
de52f7ba89 | ||
|
|
42ea78fb47 | ||
|
|
8d13bbbea5 | ||
|
|
125b03aee6 | ||
|
|
65093591cd | ||
|
|
1f91b69ca6 | ||
|
|
9676b19fba | ||
|
|
a0593b0bf8 | ||
|
|
accb918eca | ||
|
|
62be9db9df | ||
|
|
4aedbfc4a8 | ||
|
|
143cab7bf5 | ||
|
|
536865032c | ||
|
|
8edad3a455 | ||
|
|
91dc31ef39 | ||
|
|
78e09a0f1a | ||
|
|
60bae26a2f | ||
|
|
43a2c13173 | ||
|
|
f863f664ac | ||
|
|
2c13ed32e7 | ||
|
|
d4da138cf5 | ||
|
|
b286cca0c4 | ||
|
|
31a0efc747 | ||
|
|
22e97898bb | ||
|
|
1eb79df0d3 | ||
|
|
07732a5a35 | ||
|
|
3504b8cc88 | ||
|
|
4f3b5110e2 | ||
|
|
324a491671 | ||
|
|
9da4cfbdcc | ||
|
|
5e43c7faac | ||
|
|
b28ac90364 | ||
|
|
8523a52c07 | ||
|
|
ad3522e34b | ||
|
|
74e296703a | ||
|
|
25e6688af6 | ||
|
|
92edda2f6e | ||
|
|
bef520b191 | ||
|
|
ba5b6970be | ||
|
|
b000aac474 | ||
|
|
6485081122 | ||
|
|
ec7c1b6f72 | ||
|
|
eedd9b147d | ||
|
|
2157ee344b | ||
|
|
d68a2aac1a | ||
|
|
f09d63f54e | ||
|
|
f479a5d10c | ||
|
|
bd1399f81e | ||
|
|
8b37ba8828 | ||
|
|
10aa88aab9 | ||
|
|
806bb23053 | ||
|
|
3fe7b4da78 | ||
|
|
57cf2df09f | ||
|
|
1dab126944 | ||
|
|
da85cc7e47 | ||
|
|
86800a8cd0 | ||
|
|
e2028a814e | ||
|
|
23f93a9348 | ||
|
|
a8afc20969 | ||
|
|
7c717365ef | ||
|
|
4c764d3fc9 | ||
|
|
a13a6113dd | ||
|
|
cb7dfea5a9 | ||
|
|
81b8b78378 | ||
|
|
b32b815e46 | ||
|
|
91ae671a58 | ||
|
|
343390057d | ||
|
|
41cec0db05 | ||
|
|
fa1641f05f | ||
|
|
252ef8b73a | ||
|
|
53004e2e90 | ||
|
|
11ed22d052 | ||
|
|
03646e9434 | ||
|
|
2e9b8d9a64 | ||
|
|
2f715baf05 | ||
|
|
ff639ba757 | ||
|
|
16d1fbfd4e | ||
|
|
d2d3fcf57b | ||
|
|
0a64a6d8ce | ||
|
|
d8a07e91a5 | ||
|
|
a1b5c75ca3 | ||
|
|
69bc6a87dd | ||
|
|
c361b0606d | ||
|
|
ac7a141a24 | ||
|
|
527279c953 | ||
|
|
9717699367 | ||
|
|
2590b86e31 | ||
|
|
24d908b257 | ||
|
|
9c3c560d49 | ||
|
|
8711882918 | ||
|
|
27fcbace89 | ||
|
|
30d4301bea | ||
|
|
5d1c577cb2 | ||
|
|
d41a10111d | ||
|
|
93f2ccf3a5 | ||
|
|
a8927d8ec7 | ||
|
|
623a9647c0 | ||
|
|
41c71a1528 | ||
|
|
1847bdf9fd | ||
|
|
7358810659 | ||
|
|
029e5ce9f9 | ||
|
|
8cd848313c | ||
|
|
9033e14bb7 | ||
|
|
8f328d4ab3 | ||
|
|
74e78c3de0 | ||
|
|
002c0b9fda | ||
|
|
f2ca16577e | ||
|
|
6c441637b0 | ||
|
|
0f2ec134b5 | ||
|
|
2c2e50ba58 | ||
|
|
966afd1b4b | ||
|
|
54d228dda5 | ||
|
|
ad4bf5c60d | ||
|
|
e9fb5983cd | ||
|
|
98027cdded | ||
|
|
831862b924 | ||
|
|
a0778e9a42 | ||
|
|
3c68b2e369 | ||
|
|
c896cacf6d | ||
|
|
ed31a52424 | ||
|
|
f63d168ae0 | ||
|
|
3e4d48e010 | ||
|
|
40ec74b9fc | ||
|
|
a0ac5e0240 | ||
|
|
8d5bc448b5 | ||
|
|
7d239d62e8 | ||
|
|
adb5106c1f | ||
|
|
bb73416733 | ||
|
|
55ed0ab0da | ||
|
|
5aeb03018c | ||
|
|
a2bc133747 | ||
|
|
540e90ca8b | ||
|
|
4bc7486c3b | ||
|
|
664b7be30f | ||
|
|
c8cfc93632 | ||
|
|
b4aa3eb023 | ||
|
|
0465540195 | ||
|
|
b0d0de0547 | ||
|
|
bc5d6ecb46 | ||
|
|
03ccb2a08e | ||
|
|
551e2570dd | ||
|
|
8315defe8e | ||
|
|
fc9b489106 | ||
|
|
d774948e09 | ||
|
|
baab0adf7c | ||
|
|
a60a282473 | ||
|
|
317de9c04b | ||
|
|
b8dcae3bcf | ||
|
|
fbc3f42a22 | ||
|
|
98cc641008 | ||
|
|
daf0b99dcb | ||
|
|
90bff0ea17 | ||
|
|
d8fa425a27 | ||
|
|
50e5372fed | ||
|
|
c7c933776d | ||
|
|
1d86c1bf81 | ||
|
|
b6427d4ec0 | ||
|
|
b0fa2b352f | ||
|
|
54a8606e2a | ||
|
|
a7ddca8930 | ||
|
|
a5d40c9845 | ||
|
|
b0441ca5aa | ||
|
|
3605a316af | ||
|
|
1e97e4cee4 | ||
|
|
0d9c140bc2 | ||
|
|
a1930ea6f2 | ||
|
|
909b96edd2 | ||
|
|
83c10133d1 | ||
|
|
b73d8c3efe | ||
|
|
0d318195bf | ||
|
|
e5af699d0f | ||
|
|
f73031f436 | ||
|
|
64a2a31729 | ||
|
|
8a22dc7347 | ||
|
|
aeb34d2003 | ||
|
|
c4ff604e93 | ||
|
|
ce3f12bbff | ||
|
|
f187f08ae3 | ||
|
|
cbddb57dec | ||
|
|
3375b4ed2b | ||
|
|
d9e466ad3a | ||
|
|
ad18ee7c4f | ||
|
|
038b25c0b1 | ||
|
|
139f79b85a | ||
|
|
7cbb36f278 | ||
|
|
92dcfb5ebc | ||
|
|
42f3c22830 | ||
|
|
8d51364c3d | ||
|
|
33b1d772f7 | ||
|
|
1c214eaca4 | ||
|
|
841481e051 | ||
|
|
2f4478fd32 | ||
|
|
877bee84a1 | ||
|
|
d734dbdac4 | ||
|
|
05865d9b65 | ||
|
|
0dce07ee8b | ||
|
|
b6bb74b6fa | ||
|
|
7050070aa3 | ||
|
|
ff5c216189 | ||
|
|
fe184f8c33 | ||
|
|
73ffd13430 | ||
|
|
f57d06d959 | ||
|
|
1a8401f5f3 | ||
|
|
06af8115f7 | ||
|
|
5015b7a1c1 | ||
|
|
d5b47c2142 | ||
|
|
1dedb2097d | ||
|
|
52071d391b | ||
|
|
0478fd3668 | ||
|
|
c56a64fabd | ||
|
|
1c02158c2a | ||
|
|
80b7a8d4cb | ||
|
|
94a22b62f7 | ||
|
|
51de58b37f | ||
|
|
84bb7d0709 | ||
|
|
6e2bb31281 | ||
|
|
4d4ba64bf7 | ||
|
|
0464856c4a | ||
|
|
e316ebf528 | ||
|
|
07fac34713 | ||
|
|
151b9265fc | ||
|
|
6c588bb950 | ||
|
|
90e9d03dec | ||
|
|
e2b68d1f90 | ||
|
|
a2cdaa6419 | ||
|
|
bc3cb29027 | ||
|
|
89a551b964 | ||
|
|
83f169e4fa | ||
|
|
eb4d005031 | ||
|
|
0d28f7f90d | ||
|
|
f1082bb78f | ||
|
|
7942338328 | ||
|
|
18e26c127c | ||
|
|
27e29f0562 | ||
|
|
75fb3c41c2 |
@@ -8,6 +8,15 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
shell:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check deployment scripts
|
||||
run: |
|
||||
/bin/bash -n deploy/apple-container.sh
|
||||
/bin/bash deploy/tests/apple-container-test.sh
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -116,6 +116,8 @@ backend/.installed
|
||||
# 其他
|
||||
# ===================
|
||||
tests
|
||||
!deploy/tests/
|
||||
!deploy/tests/**
|
||||
CLAUDE.md
|
||||
.claude
|
||||
scripts
|
||||
@@ -133,6 +135,7 @@ docs/*
|
||||
!docs/PAYMENT.md
|
||||
!docs/PAYMENT_CN.md
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
!docs/ASYNC_IMAGE_TASKS.md
|
||||
!docs/legal/
|
||||
!docs/legal/*.md
|
||||
.serena/
|
||||
|
||||
@@ -163,6 +163,12 @@ Model authenticity: no content intervention or secondary filtering — experienc
|
||||
</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>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
@@ -329,6 +335,7 @@ cd sub2api/deploy
|
||||
|
||||
# 2. Copy environment configuration
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
|
||||
# 3. Edit configuration (generate secure passwords)
|
||||
nano .env
|
||||
@@ -448,7 +455,23 @@ rm -rf data/ postgres_data/ redis_data/
|
||||
|
||||
---
|
||||
|
||||
### Method 3: Build from Source
|
||||
### Method 3: Apple container (macOS)
|
||||
|
||||
Apple-silicon Macs running macOS 26 can run the full Sub2API, PostgreSQL, and Redis stack with Apple `container` 1.1.0 or newer:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
./apple-container.sh init
|
||||
./apple-container.sh up
|
||||
./apple-container.sh status
|
||||
```
|
||||
|
||||
This is an operator-managed local workflow; Docker Compose remains the recommended production path. See [deploy/APPLE_CONTAINER.md](deploy/APPLE_CONTAINER.md) for lifecycle commands, persistence, upgrades, and runtime limitations.
|
||||
|
||||
---
|
||||
|
||||
### Method 4: Build from Source
|
||||
|
||||
Build and run from source code for development or customization.
|
||||
|
||||
@@ -579,6 +602,34 @@ If you disable URL validation or response header filtering, harden your network
|
||||
- Enforce TLS-only outbound traffic
|
||||
- Strip sensitive upstream response headers at the proxy
|
||||
|
||||
#### OpenAI Responses WebSocket ingress limits
|
||||
|
||||
`gateway.openai_ws` bounds the lifetime and aggregate count of client-facing
|
||||
Responses WebSocket sessions. These safeguards apply independently from
|
||||
per-turn user and account concurrency slots, which are released between turns.
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
openai_ws:
|
||||
# Total time to receive and decompress the first client message.
|
||||
client_first_message_timeout_seconds: 30
|
||||
# Close a client socket idle between completed turns; 0 disables this safeguard.
|
||||
ingress_inter_turn_idle_timeout_seconds: 300
|
||||
# Distributed API-key limit for live client ingress sessions; 0 disables it.
|
||||
max_ingress_connections_per_api_key: 64
|
||||
```
|
||||
|
||||
The first-message timeout is a total read deadline. Deployments that accept
|
||||
large contexts or image-heavy requests over slower links can raise it to
|
||||
120-300 seconds. It expires before HTTP bridge routing, so bridge mode does not
|
||||
override this limit.
|
||||
|
||||
The connection cap is coordinated through Redis using a 60-second lease that
|
||||
is refreshed every 20 seconds. A process that cannot confirm a lease for a
|
||||
full lease lifetime closes its local WebSocket rather than continuing outside
|
||||
the global cap. Use `http_bridge` for client-WebSocket/upstream-HTTP operation
|
||||
when rolling out or mitigating upstream WebSocket issues.
|
||||
|
||||
#### ⚠️ Important: Creating the Admin Account
|
||||
|
||||
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
|
||||
@@ -637,20 +688,26 @@ Simple Mode is designed for individual developers or internal teams who want qui
|
||||
|
||||
---
|
||||
|
||||
## Grok / xAI OAuth Support
|
||||
## Asynchronous Image Tasks
|
||||
|
||||
Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI.
|
||||
Long-running OpenAI/Grok image generation and editing can be submitted through `/v1/images/generations/async` or `/v1/images/edits/async`, then polled at `/v1/images/tasks/{task_id}` without holding a CDN connection open. See [Asynchronous Image Tasks](docs/ASYNC_IMAGE_TASKS.md) for request and response examples.
|
||||
|
||||
---
|
||||
|
||||
## Grok / xAI Support
|
||||
|
||||
Sub2API supports both Grok subscription accounts through xAI OAuth and standard xAI API-key accounts. Both account types forward OpenAI-compatible Responses traffic to xAI.
|
||||
|
||||
### Supported Scope
|
||||
|
||||
- Platform name: `grok`
|
||||
- Account type: OAuth subscription accounts
|
||||
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
|
||||
- Account types: OAuth subscription accounts and xAI API-key accounts
|
||||
- Public Responses targets: `/v1/responses`, `/responses`, and `/backend-api/codex/responses`, forwarded to the Grok subscription proxy for OAuth accounts or `https://api.x.ai/v1/responses` for API-key accounts
|
||||
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
|
||||
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
|
||||
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to the account-type-specific xAI upstream
|
||||
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
|
||||
- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission.
|
||||
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission.
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
|
||||
@@ -665,9 +722,10 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T
|
||||
| `XAI_OAUTH_REDIRECT_URI` | `http://127.0.0.1:56121/callback` |
|
||||
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
|
||||
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
|
||||
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
||||
| `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) |
|
||||
| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` |
|
||||
|
||||
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
|
||||
Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
@@ -676,13 +734,46 @@ Administrators can create or reauthorize Grok accounts from the dashboard, or us
|
||||
| `POST /api/v1/admin/grok/oauth/refresh-token` | Validate or refresh a Grok refresh token |
|
||||
| `POST /api/v1/admin/grok/accounts/:id/refresh` | Refresh an existing Grok account |
|
||||
|
||||
Credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
|
||||
OAuth credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, `base_url`, optional `email`, optional `subscription_tier`, and `entitlement_status`. OAuth inference defaults to `https://cli-chat-proxy.grok.com/v1`; existing OAuth accounts that stored the old `https://api.x.ai/v1` default are redirected to the subscription proxy at runtime. Explicit custom upstreams remain unchanged.
|
||||
|
||||
For API-key accounts, select **Grok → API Key** in the create-account dialog. The official base URL defaults to `https://api.x.ai/v1`; credentials use the existing `base_url` and `api_key` account fields. OAuth accounts continue to use the subscription flow above.
|
||||
|
||||
### Grok Build CLI Configuration
|
||||
|
||||
1. In the Sub2API admin dashboard, add either a `grok` OAuth account and complete xAI authorization, or add a Grok API-key account.
|
||||
2. Create a Grok group, attach the account to it, then create a Sub2API API key assigned to that group.
|
||||
3. In the user API-key page, click **Use Key** and select **Grok CLI**. The modal generates the correct file and base URL for macOS/Linux or Windows. It also provides an OpenCode configuration on the **OpenCode** tab.
|
||||
4. If configuring manually, save the following as `~/.grok/config.toml` (Windows: `%USERPROFILE%\.grok\config.toml`):
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "grok"
|
||||
web_search = "grok"
|
||||
|
||||
[model."grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://your-sub2api.example.com/v1"
|
||||
name = "Grok 4.5"
|
||||
api_key = "sk-your-sub2api-key"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
supports_backend_search = true
|
||||
```
|
||||
|
||||
Back up an existing `config.toml` before merging the entry. The file contains a Sub2API API key, so keep it private and restrict its permissions where supported. Verify the effective configuration and make a smoke request:
|
||||
|
||||
```bash
|
||||
grok inspect
|
||||
grok -p "Reply with sub2api-ok" -m grok
|
||||
```
|
||||
|
||||
The `base_url` above is the public Sub2API URL ending in `/v1`, not `api.x.ai` or the internal xAI OAuth proxy URL.
|
||||
|
||||
### Usage And Quota Display
|
||||
|
||||
xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats.
|
||||
|
||||
`401` responses mark the account as needing reauthorization. `403` responses are treated as entitlement or subscription-tier failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+23
-1
@@ -166,6 +166,11 @@
|
||||
</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>
|
||||
|
||||
</table>
|
||||
|
||||
@@ -333,6 +338,7 @@ cd sub2api/deploy
|
||||
|
||||
# 2. 复制环境配置文件
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
|
||||
# 3. 编辑配置(生成安全密码)
|
||||
nano .env
|
||||
@@ -464,7 +470,23 @@ rm -rf data/ postgres_data/ redis_data/
|
||||
|
||||
---
|
||||
|
||||
### 方式三:源码编译
|
||||
### 方式三:Apple container(macOS)
|
||||
|
||||
Apple 芯片 Mac 在 macOS 26 上可使用 Apple `container` 1.1.0 或更高版本运行完整的 Sub2API、PostgreSQL 和 Redis:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
./apple-container.sh init
|
||||
./apple-container.sh up
|
||||
./apple-container.sh status
|
||||
```
|
||||
|
||||
该方式面向本地开发和人工运维,不提供持续重启监管;生产部署仍推荐 Docker Compose。生命周期命令、持久化、升级和运行时限制见 [deploy/APPLE_CONTAINER.md](deploy/APPLE_CONTAINER.md)。
|
||||
|
||||
---
|
||||
|
||||
### 方式四:源码编译
|
||||
|
||||
从源码编译安装,适合开发或定制需求。
|
||||
|
||||
|
||||
+24
-1
@@ -161,6 +161,12 @@
|
||||
</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>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
@@ -327,6 +333,7 @@ cd sub2api/deploy
|
||||
|
||||
# 2. 環境設定ファイルをコピー
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
|
||||
# 3. 設定を編集(セキュアなパスワードを生成)
|
||||
nano .env
|
||||
@@ -446,7 +453,23 @@ rm -rf data/ postgres_data/ redis_data/
|
||||
|
||||
---
|
||||
|
||||
### 方法3: ソースからビルド
|
||||
### 方法3: Apple container(macOS)
|
||||
|
||||
Apple シリコン搭載 Mac と macOS 26 では、Apple `container` 1.1.0 以降を使用して Sub2API、PostgreSQL、Redis の完全なスタックを実行できます:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
./apple-container.sh init
|
||||
./apple-container.sh up
|
||||
./apple-container.sh status
|
||||
```
|
||||
|
||||
これはローカル開発および手動運用向けです。本番環境では引き続き Docker Compose を推奨します。ライフサイクル、永続化、アップグレード、制限については [deploy/APPLE_CONTAINER.md](deploy/APPLE_CONTAINER.md) を参照してください。
|
||||
|
||||
---
|
||||
|
||||
### 方法4: ソースからビルド
|
||||
|
||||
開発やカスタマイズのためにソースコードからビルドして実行します。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -48,7 +48,7 @@ func main() {
|
||||
log.Fatalf("failed to resolve admin user: %v", err)
|
||||
}
|
||||
|
||||
token, err := authService.GenerateToken(user)
|
||||
token, err := authService.GenerateToken(ctx, user)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.150
|
||||
0.1.156
|
||||
|
||||
@@ -103,6 +103,8 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
auditLog *service.AuditLogService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -133,6 +135,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuditLogService", func() error {
|
||||
if auditLog != nil {
|
||||
auditLog.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsAlertEvaluatorService", func() error {
|
||||
if opsAlertEvaluator != nil {
|
||||
opsAlertEvaluator.Stop()
|
||||
@@ -279,6 +287,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"UpstreamBillingProbeService", func() error {
|
||||
if upstreamBillingProbe != nil {
|
||||
upstreamBillingProbe.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -96,9 +96,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
|
||||
opsRepository := repository.NewOpsRepository(db)
|
||||
batchImageRepository := repository.NewBatchImageRepository(db)
|
||||
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
|
||||
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
|
||||
usageBillingRepository := repository.NewUsageBillingRepository(client, db)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||
@@ -137,11 +134,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver)
|
||||
batchImagePublicService := service.NewBatchImagePublicService(batchImageRepository, accountRepository, groupRepository, userGroupRateRepository, batchImageQueue, batchImageModelPricingResolver, usageBillingRepository, apiKeyAuthCacheInvalidator, configConfig)
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
|
||||
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
|
||||
@@ -181,21 +173,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
leaderLockCache := repository.NewLeaderLockCache(redisClient)
|
||||
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
|
||||
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache)
|
||||
adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
|
||||
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
@@ -207,8 +201,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream)
|
||||
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||
promoHandler := admin.NewPromoHandler(promoService)
|
||||
@@ -256,40 +250,60 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||
complianceHandler := admin.NewComplianceHandler(settingService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
auditLogRepository := repository.NewAuditLogRepository(db)
|
||||
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
|
||||
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
|
||||
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, configConfig)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
imageTaskStore := repository.NewImageTaskStore(redisClient)
|
||||
imageStorage, err := repository.ProvideImageStorage(configConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorage, configConfig)
|
||||
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
|
||||
batchImageRepository := repository.NewBatchImageRepository(db)
|
||||
batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig)
|
||||
batchImageModelPricingResolver := service.ProvideBatchImageModelPricingResolver(modelPricingResolver)
|
||||
batchImagePublicService := service.NewBatchImagePublicService(batchImageRepository, accountRepository, groupRepository, userGroupRateRepository, batchImageQueue, batchImageModelPricingResolver, usageBillingRepository, apiKeyAuthCacheInvalidator, configConfig)
|
||||
batchImageDownloadLimiter := repository.NewBatchImageDownloadLimiter(redisClient, configConfig)
|
||||
batchImageDownloadService := service.NewBatchImageDownloadService(batchImageRepository, accountRepository, batchImageDownloadLimiter, configConfig)
|
||||
batchImageCleanupService := service.ProvideBatchImageCleanupService(batchImageRepository, accountRepository, configConfig)
|
||||
batchImageHandler := handler.NewBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
|
||||
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
|
||||
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
|
||||
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository)
|
||||
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -349,6 +363,8 @@ func provideCleanup(
|
||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||
channelMonitorRunner *service.ChannelMonitorRunner,
|
||||
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService,
|
||||
auditLog *service.AuditLogService,
|
||||
) func() {
|
||||
return func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -378,6 +394,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"AuditLogService", func() error {
|
||||
if auditLog != nil {
|
||||
auditLog.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpsAlertEvaluatorService", func() error {
|
||||
if opsAlertEvaluator != nil {
|
||||
opsAlertEvaluator.Stop()
|
||||
@@ -524,6 +546,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"UpstreamBillingProbeService", func() error {
|
||||
if upstreamBillingProbe != nil {
|
||||
upstreamBillingProbe.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -83,6 +83,8 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil, // paymentOrderExpiry
|
||||
nil, // channelMonitorRunner
|
||||
nil, // quotaFlusher
|
||||
nil, // upstreamBillingProbe
|
||||
nil, // auditLog
|
||||
)
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
|
||||
@@ -167,6 +167,7 @@ const (
|
||||
ProviderOpenai Provider = "openai"
|
||||
ProviderAnthropic Provider = "anthropic"
|
||||
ProviderGemini Provider = "gemini"
|
||||
ProviderGrok Provider = "grok"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -176,7 +177,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -103,6 +103,7 @@ const (
|
||||
ProviderOpenai Provider = "openai"
|
||||
ProviderAnthropic Provider = "anthropic"
|
||||
ProviderGemini Provider = "gemini"
|
||||
ProviderGrok Provider = "grok"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -112,7 +113,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||
|
||||
+15
-1
@@ -83,6 +83,8 @@ type Group struct {
|
||||
VideoPrice720p *float64 `json:"video_price_720p,omitempty"`
|
||||
// VideoPrice1080p holds the value of the "video_price_1080p" field.
|
||||
VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"`
|
||||
// Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"`
|
||||
// 是否仅允许 Claude Code 客户端
|
||||
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
|
||||
// 非 Claude Code 请求降级使用的分组 ID
|
||||
@@ -223,7 +225,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p:
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
@@ -455,6 +457,13 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
_m.VideoPrice1080p = new(float64)
|
||||
*_m.VideoPrice1080p = value.Float64
|
||||
}
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i])
|
||||
} else if value.Valid {
|
||||
_m.WebSearchPricePerCall = new(float64)
|
||||
*_m.WebSearchPricePerCall = value.Float64
|
||||
}
|
||||
case group.FieldClaudeCodeOnly:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
|
||||
@@ -749,6 +758,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.WebSearchPricePerCall; v != nil {
|
||||
builder.WriteString("web_search_price_per_call=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("claude_code_only=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -80,6 +80,8 @@ const (
|
||||
FieldVideoPrice720p = "video_price_720p"
|
||||
// FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database.
|
||||
FieldVideoPrice1080p = "video_price_1080p"
|
||||
// FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database.
|
||||
FieldWebSearchPricePerCall = "web_search_price_per_call"
|
||||
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
|
||||
FieldClaudeCodeOnly = "claude_code_only"
|
||||
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
|
||||
@@ -217,6 +219,7 @@ var Columns = []string{
|
||||
FieldVideoPrice480p,
|
||||
FieldVideoPrice720p,
|
||||
FieldVideoPrice1080p,
|
||||
FieldWebSearchPricePerCall,
|
||||
FieldClaudeCodeOnly,
|
||||
FieldFallbackGroupID,
|
||||
FieldFallbackGroupIDOnInvalidRequest,
|
||||
@@ -511,6 +514,11 @@ func ByVideoPrice1080p(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldVideoPrice1080p, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByWebSearchPricePerCall orders the results by the web_search_price_per_call field.
|
||||
func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByClaudeCodeOnly orders the results by the claude_code_only field.
|
||||
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
|
||||
|
||||
@@ -215,6 +215,11 @@ func VideoPrice1080p(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldVideoPrice1080p, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCall applies equality check predicate on the "web_search_price_per_call" field. It's identical to WebSearchPricePerCallEQ.
|
||||
func WebSearchPricePerCall(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
|
||||
func ClaudeCodeOnly(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
|
||||
@@ -1655,6 +1660,56 @@ func VideoPrice1080pNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallNEQ applies the NEQ predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallIn applies the In predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldWebSearchPricePerCall, vs...))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallNotIn applies the NotIn predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldWebSearchPricePerCall, vs...))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallGT applies the GT predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallGTE applies the GTE predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallLT applies the LT predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallLTE applies the LTE predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldWebSearchPricePerCall, v))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallIsNil applies the IsNil predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallIsNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldIsNull(FieldWebSearchPricePerCall))
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallNotNil applies the NotNil predicate on the "web_search_price_per_call" field.
|
||||
func WebSearchPricePerCallNotNil() predicate.Group {
|
||||
return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall))
|
||||
}
|
||||
|
||||
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
|
||||
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
|
||||
|
||||
@@ -469,6 +469,20 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate {
|
||||
_c.mutation.SetWebSearchPricePerCall(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetWebSearchPricePerCall(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
|
||||
_c.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -1218,6 +1232,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
|
||||
_node.VideoPrice1080p = &value
|
||||
}
|
||||
if value, ok := _c.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
_node.WebSearchPricePerCall = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
_node.ClaudeCodeOnly = value
|
||||
@@ -1968,6 +1986,30 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldWebSearchPricePerCall, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateWebSearchPricePerCall() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldWebSearchPricePerCall)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsert) AddWebSearchPricePerCall(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldWebSearchPricePerCall, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert {
|
||||
u.SetNull(group.FieldWebSearchPricePerCall)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldClaudeCodeOnly, v)
|
||||
@@ -2858,6 +2900,34 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetWebSearchPricePerCall(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertOne) AddWebSearchPricePerCall(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddWebSearchPricePerCall(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateWebSearchPricePerCall() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateWebSearchPricePerCall()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearWebSearchPricePerCall()
|
||||
})
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3951,6 +4021,34 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetWebSearchPricePerCall(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds v to the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertBulk) AddWebSearchPricePerCall(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddWebSearchPricePerCall(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateWebSearchPricePerCall sets the "web_search_price_per_call" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateWebSearchPricePerCall() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateWebSearchPricePerCall()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.ClearWebSearchPricePerCall()
|
||||
})
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -640,6 +640,33 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetWebSearchPricePerCall()
|
||||
_u.mutation.SetWebSearchPricePerCall(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableWebSearchPricePerCall(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetWebSearchPricePerCall(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds value to the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdate) AddWebSearchPricePerCall(v float64) *GroupUpdate {
|
||||
_u.mutation.AddWebSearchPricePerCall(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate {
|
||||
_u.mutation.ClearWebSearchPricePerCall()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
|
||||
_u.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -1375,6 +1402,15 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.VideoPrice1080pCleared() {
|
||||
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedWebSearchPricePerCall(); ok {
|
||||
_spec.AddField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.WebSearchPricePerCallCleared() {
|
||||
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -2364,6 +2400,33 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetWebSearchPricePerCall()
|
||||
_u.mutation.SetWebSearchPricePerCall(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableWebSearchPricePerCall sets the "web_search_price_per_call" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableWebSearchPricePerCall(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetWebSearchPricePerCall(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds value to the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdateOne) AddWebSearchPricePerCall(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddWebSearchPricePerCall(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne {
|
||||
_u.mutation.ClearWebSearchPricePerCall()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetClaudeCodeOnly(v)
|
||||
@@ -3129,6 +3192,15 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if _u.mutation.VideoPrice1080pCleared() {
|
||||
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
|
||||
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedWebSearchPricePerCall(); ok {
|
||||
_spec.AddField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.WebSearchPricePerCallCleared() {
|
||||
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
|
||||
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "endpoint", Type: field.TypeString, Size: 500},
|
||||
{Name: "api_key_encrypted", Type: field.TypeString},
|
||||
@@ -768,7 +768,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||
{Name: "extra_headers", Type: field.TypeJSON},
|
||||
@@ -865,6 +865,7 @@ var (
|
||||
{Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
|
||||
@@ -915,7 +916,7 @@ var (
|
||||
{
|
||||
Name: "group_sort_order",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{GroupsColumns[40]},
|
||||
Columns: []*schema.Column{GroupsColumns[41]},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1446,6 +1447,7 @@ var (
|
||||
{Name: "description", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
|
||||
{Name: "price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "original_price", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
|
||||
{Name: "currency", Type: field.TypeString, Size: 3, Default: ""},
|
||||
{Name: "validity_days", Type: field.TypeInt, Default: 30},
|
||||
{Name: "validity_unit", Type: field.TypeString, Size: 10, Default: "day"},
|
||||
{Name: "features", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
|
||||
@@ -1469,7 +1471,7 @@ var (
|
||||
{
|
||||
Name: "subscriptionplan_for_sale",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{SubscriptionPlansColumns[10]},
|
||||
Columns: []*schema.Column{SubscriptionPlansColumns[11]},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1559,6 +1561,7 @@ var (
|
||||
{Name: "total_cost", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
|
||||
{Name: "actual_cost", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
|
||||
{Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "long_context_billing_applied", Type: field.TypeBool, Default: false},
|
||||
{Name: "account_rate_multiplier", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "billing_type", Type: field.TypeInt8, Default: 0},
|
||||
{Name: "stream", Type: field.TypeBool, Default: false},
|
||||
@@ -1591,31 +1594,31 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "usage_logs_api_keys_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_accounts_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_groups_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_users_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@@ -1624,32 +1627,32 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_subscription_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[39]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_model",
|
||||
@@ -1669,17 +1672,17 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[39]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[39]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[42], UsageLogsColumns[39]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+295
-80
@@ -20842,6 +20842,8 @@ type GroupMutation struct {
|
||||
addvideo_price_720p *float64
|
||||
video_price_1080p *float64
|
||||
addvideo_price_1080p *float64
|
||||
web_search_price_per_call *float64
|
||||
addweb_search_price_per_call *float64
|
||||
claude_code_only *bool
|
||||
fallback_group_id *int64
|
||||
addfallback_group_id *int64
|
||||
@@ -22608,6 +22610,76 @@ func (m *GroupMutation) ResetVideoPrice1080p() {
|
||||
delete(m.clearedFields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
|
||||
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
|
||||
func (m *GroupMutation) SetWebSearchPricePerCall(f float64) {
|
||||
m.web_search_price_per_call = &f
|
||||
m.addweb_search_price_per_call = nil
|
||||
}
|
||||
|
||||
// WebSearchPricePerCall returns the value of the "web_search_price_per_call" field in the mutation.
|
||||
func (m *GroupMutation) WebSearchPricePerCall() (r float64, exists bool) {
|
||||
v := m.web_search_price_per_call
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldWebSearchPricePerCall returns the old "web_search_price_per_call" 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) OldWebSearchPricePerCall(ctx context.Context) (v *float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldWebSearchPricePerCall is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldWebSearchPricePerCall requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldWebSearchPricePerCall: %w", err)
|
||||
}
|
||||
return oldValue.WebSearchPricePerCall, nil
|
||||
}
|
||||
|
||||
// AddWebSearchPricePerCall adds f to the "web_search_price_per_call" field.
|
||||
func (m *GroupMutation) AddWebSearchPricePerCall(f float64) {
|
||||
if m.addweb_search_price_per_call != nil {
|
||||
*m.addweb_search_price_per_call += f
|
||||
} else {
|
||||
m.addweb_search_price_per_call = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedWebSearchPricePerCall returns the value that was added to the "web_search_price_per_call" field in this mutation.
|
||||
func (m *GroupMutation) AddedWebSearchPricePerCall() (r float64, exists bool) {
|
||||
v := m.addweb_search_price_per_call
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ClearWebSearchPricePerCall clears the value of the "web_search_price_per_call" field.
|
||||
func (m *GroupMutation) ClearWebSearchPricePerCall() {
|
||||
m.web_search_price_per_call = nil
|
||||
m.addweb_search_price_per_call = nil
|
||||
m.clearedFields[group.FieldWebSearchPricePerCall] = struct{}{}
|
||||
}
|
||||
|
||||
// WebSearchPricePerCallCleared returns if the "web_search_price_per_call" field was cleared in this mutation.
|
||||
func (m *GroupMutation) WebSearchPricePerCallCleared() bool {
|
||||
_, ok := m.clearedFields[group.FieldWebSearchPricePerCall]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetWebSearchPricePerCall resets all changes to the "web_search_price_per_call" field.
|
||||
func (m *GroupMutation) ResetWebSearchPricePerCall() {
|
||||
m.web_search_price_per_call = nil
|
||||
m.addweb_search_price_per_call = nil
|
||||
delete(m.clearedFields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
|
||||
// SetClaudeCodeOnly sets the "claude_code_only" field.
|
||||
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
|
||||
m.claude_code_only = &b
|
||||
@@ -23642,7 +23714,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, 47)
|
||||
fields := make([]string, 0, 48)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -23739,6 +23811,9 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.video_price_1080p != nil {
|
||||
fields = append(fields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
if m.web_search_price_per_call != nil {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.claude_code_only != nil {
|
||||
fields = append(fields, group.FieldClaudeCodeOnly)
|
||||
}
|
||||
@@ -23856,6 +23931,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.VideoPrice720p()
|
||||
case group.FieldVideoPrice1080p:
|
||||
return m.VideoPrice1080p()
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.WebSearchPricePerCall()
|
||||
case group.FieldClaudeCodeOnly:
|
||||
return m.ClaudeCodeOnly()
|
||||
case group.FieldFallbackGroupID:
|
||||
@@ -23959,6 +24036,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldVideoPrice720p(ctx)
|
||||
case group.FieldVideoPrice1080p:
|
||||
return m.OldVideoPrice1080p(ctx)
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.OldWebSearchPricePerCall(ctx)
|
||||
case group.FieldClaudeCodeOnly:
|
||||
return m.OldClaudeCodeOnly(ctx)
|
||||
case group.FieldFallbackGroupID:
|
||||
@@ -24222,6 +24301,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetVideoPrice1080p(v)
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetWebSearchPricePerCall(v)
|
||||
return nil
|
||||
case group.FieldClaudeCodeOnly:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -24383,6 +24469,9 @@ func (m *GroupMutation) AddedFields() []string {
|
||||
if m.addvideo_price_1080p != nil {
|
||||
fields = append(fields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
if m.addweb_search_price_per_call != nil {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.addfallback_group_id != nil {
|
||||
fields = append(fields, group.FieldFallbackGroupID)
|
||||
}
|
||||
@@ -24435,6 +24524,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
|
||||
return m.AddedVideoPrice720p()
|
||||
case group.FieldVideoPrice1080p:
|
||||
return m.AddedVideoPrice1080p()
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
return m.AddedWebSearchPricePerCall()
|
||||
case group.FieldFallbackGroupID:
|
||||
return m.AddedFallbackGroupID()
|
||||
case group.FieldFallbackGroupIDOnInvalidRequest:
|
||||
@@ -24564,6 +24655,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddVideoPrice1080p(v)
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddWebSearchPricePerCall(v)
|
||||
return nil
|
||||
case group.FieldFallbackGroupID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
@@ -24633,6 +24731,9 @@ func (m *GroupMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(group.FieldVideoPrice1080p) {
|
||||
fields = append(fields, group.FieldVideoPrice1080p)
|
||||
}
|
||||
if m.FieldCleared(group.FieldWebSearchPricePerCall) {
|
||||
fields = append(fields, group.FieldWebSearchPricePerCall)
|
||||
}
|
||||
if m.FieldCleared(group.FieldFallbackGroupID) {
|
||||
fields = append(fields, group.FieldFallbackGroupID)
|
||||
}
|
||||
@@ -24689,6 +24790,9 @@ func (m *GroupMutation) ClearField(name string) error {
|
||||
case group.FieldVideoPrice1080p:
|
||||
m.ClearVideoPrice1080p()
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
m.ClearWebSearchPricePerCall()
|
||||
return nil
|
||||
case group.FieldFallbackGroupID:
|
||||
m.ClearFallbackGroupID()
|
||||
return nil
|
||||
@@ -24802,6 +24906,9 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldVideoPrice1080p:
|
||||
m.ResetVideoPrice1080p()
|
||||
return nil
|
||||
case group.FieldWebSearchPricePerCall:
|
||||
m.ResetWebSearchPricePerCall()
|
||||
return nil
|
||||
case group.FieldClaudeCodeOnly:
|
||||
m.ResetClaudeCodeOnly()
|
||||
return nil
|
||||
@@ -38045,6 +38152,7 @@ type SubscriptionPlanMutation struct {
|
||||
addprice *float64
|
||||
original_price *float64
|
||||
addoriginal_price *float64
|
||||
currency *string
|
||||
validity_days *int
|
||||
addvalidity_days *int
|
||||
validity_unit *string
|
||||
@@ -38413,6 +38521,42 @@ func (m *SubscriptionPlanMutation) ResetOriginalPrice() {
|
||||
delete(m.clearedFields, subscriptionplan.FieldOriginalPrice)
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (m *SubscriptionPlanMutation) SetCurrency(s string) {
|
||||
m.currency = &s
|
||||
}
|
||||
|
||||
// Currency returns the value of the "currency" field in the mutation.
|
||||
func (m *SubscriptionPlanMutation) Currency() (r string, exists bool) {
|
||||
v := m.currency
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldCurrency returns the old "currency" field's value of the SubscriptionPlan entity.
|
||||
// If the SubscriptionPlan 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 *SubscriptionPlanMutation) OldCurrency(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldCurrency is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldCurrency requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldCurrency: %w", err)
|
||||
}
|
||||
return oldValue.Currency, nil
|
||||
}
|
||||
|
||||
// ResetCurrency resets all changes to the "currency" field.
|
||||
func (m *SubscriptionPlanMutation) ResetCurrency() {
|
||||
m.currency = nil
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (m *SubscriptionPlanMutation) SetValidityDays(i int) {
|
||||
m.validity_days = &i
|
||||
@@ -38775,7 +38919,7 @@ func (m *SubscriptionPlanMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *SubscriptionPlanMutation) Fields() []string {
|
||||
fields := make([]string, 0, 13)
|
||||
fields := make([]string, 0, 14)
|
||||
if m.group_id != nil {
|
||||
fields = append(fields, subscriptionplan.FieldGroupID)
|
||||
}
|
||||
@@ -38791,6 +38935,9 @@ func (m *SubscriptionPlanMutation) Fields() []string {
|
||||
if m.original_price != nil {
|
||||
fields = append(fields, subscriptionplan.FieldOriginalPrice)
|
||||
}
|
||||
if m.currency != nil {
|
||||
fields = append(fields, subscriptionplan.FieldCurrency)
|
||||
}
|
||||
if m.validity_days != nil {
|
||||
fields = append(fields, subscriptionplan.FieldValidityDays)
|
||||
}
|
||||
@@ -38833,6 +38980,8 @@ func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.Price()
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
return m.OriginalPrice()
|
||||
case subscriptionplan.FieldCurrency:
|
||||
return m.Currency()
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
return m.ValidityDays()
|
||||
case subscriptionplan.FieldValidityUnit:
|
||||
@@ -38868,6 +39017,8 @@ func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (e
|
||||
return m.OldPrice(ctx)
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
return m.OldOriginalPrice(ctx)
|
||||
case subscriptionplan.FieldCurrency:
|
||||
return m.OldCurrency(ctx)
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
return m.OldValidityDays(ctx)
|
||||
case subscriptionplan.FieldValidityUnit:
|
||||
@@ -38928,6 +39079,13 @@ func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error
|
||||
}
|
||||
m.SetOriginalPrice(v)
|
||||
return nil
|
||||
case subscriptionplan.FieldCurrency:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetCurrency(v)
|
||||
return nil
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
v, ok := value.(int)
|
||||
if !ok {
|
||||
@@ -39120,6 +39278,9 @@ func (m *SubscriptionPlanMutation) ResetField(name string) error {
|
||||
case subscriptionplan.FieldOriginalPrice:
|
||||
m.ResetOriginalPrice()
|
||||
return nil
|
||||
case subscriptionplan.FieldCurrency:
|
||||
m.ResetCurrency()
|
||||
return nil
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
m.ResetValidityDays()
|
||||
return nil
|
||||
@@ -41656,83 +41817,84 @@ func (m *UsageCleanupTaskMutation) ResetEdge(name string) error {
|
||||
// UsageLogMutation represents an operation that mutates the UsageLog nodes in the graph.
|
||||
type UsageLogMutation struct {
|
||||
config
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
request_id *string
|
||||
model *string
|
||||
requested_model *string
|
||||
upstream_model *string
|
||||
channel_id *int64
|
||||
addchannel_id *int64
|
||||
model_mapping_chain *string
|
||||
billing_tier *string
|
||||
billing_mode *string
|
||||
input_tokens *int
|
||||
addinput_tokens *int
|
||||
output_tokens *int
|
||||
addoutput_tokens *int
|
||||
cache_creation_tokens *int
|
||||
addcache_creation_tokens *int
|
||||
cache_read_tokens *int
|
||||
addcache_read_tokens *int
|
||||
cache_creation_5m_tokens *int
|
||||
addcache_creation_5m_tokens *int
|
||||
cache_creation_1h_tokens *int
|
||||
addcache_creation_1h_tokens *int
|
||||
input_cost *float64
|
||||
addinput_cost *float64
|
||||
output_cost *float64
|
||||
addoutput_cost *float64
|
||||
cache_creation_cost *float64
|
||||
addcache_creation_cost *float64
|
||||
cache_read_cost *float64
|
||||
addcache_read_cost *float64
|
||||
total_cost *float64
|
||||
addtotal_cost *float64
|
||||
actual_cost *float64
|
||||
addactual_cost *float64
|
||||
rate_multiplier *float64
|
||||
addrate_multiplier *float64
|
||||
account_rate_multiplier *float64
|
||||
addaccount_rate_multiplier *float64
|
||||
billing_type *int8
|
||||
addbilling_type *int8
|
||||
stream *bool
|
||||
duration_ms *int
|
||||
addduration_ms *int
|
||||
first_token_ms *int
|
||||
addfirst_token_ms *int
|
||||
user_agent *string
|
||||
ip_address *string
|
||||
image_count *int
|
||||
addimage_count *int
|
||||
image_size *string
|
||||
image_input_size *string
|
||||
image_output_size *string
|
||||
image_size_source *string
|
||||
image_size_breakdown *map[string]int
|
||||
video_count *int
|
||||
addvideo_count *int
|
||||
video_resolution *string
|
||||
video_duration_seconds *int
|
||||
addvideo_duration_seconds *int
|
||||
cache_ttl_overridden *bool
|
||||
created_at *time.Time
|
||||
clearedFields map[string]struct{}
|
||||
user *int64
|
||||
cleareduser bool
|
||||
api_key *int64
|
||||
clearedapi_key bool
|
||||
account *int64
|
||||
clearedaccount bool
|
||||
group *int64
|
||||
clearedgroup bool
|
||||
subscription *int64
|
||||
clearedsubscription bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*UsageLog, error)
|
||||
predicates []predicate.UsageLog
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
request_id *string
|
||||
model *string
|
||||
requested_model *string
|
||||
upstream_model *string
|
||||
channel_id *int64
|
||||
addchannel_id *int64
|
||||
model_mapping_chain *string
|
||||
billing_tier *string
|
||||
billing_mode *string
|
||||
input_tokens *int
|
||||
addinput_tokens *int
|
||||
output_tokens *int
|
||||
addoutput_tokens *int
|
||||
cache_creation_tokens *int
|
||||
addcache_creation_tokens *int
|
||||
cache_read_tokens *int
|
||||
addcache_read_tokens *int
|
||||
cache_creation_5m_tokens *int
|
||||
addcache_creation_5m_tokens *int
|
||||
cache_creation_1h_tokens *int
|
||||
addcache_creation_1h_tokens *int
|
||||
input_cost *float64
|
||||
addinput_cost *float64
|
||||
output_cost *float64
|
||||
addoutput_cost *float64
|
||||
cache_creation_cost *float64
|
||||
addcache_creation_cost *float64
|
||||
cache_read_cost *float64
|
||||
addcache_read_cost *float64
|
||||
total_cost *float64
|
||||
addtotal_cost *float64
|
||||
actual_cost *float64
|
||||
addactual_cost *float64
|
||||
rate_multiplier *float64
|
||||
addrate_multiplier *float64
|
||||
long_context_billing_applied *bool
|
||||
account_rate_multiplier *float64
|
||||
addaccount_rate_multiplier *float64
|
||||
billing_type *int8
|
||||
addbilling_type *int8
|
||||
stream *bool
|
||||
duration_ms *int
|
||||
addduration_ms *int
|
||||
first_token_ms *int
|
||||
addfirst_token_ms *int
|
||||
user_agent *string
|
||||
ip_address *string
|
||||
image_count *int
|
||||
addimage_count *int
|
||||
image_size *string
|
||||
image_input_size *string
|
||||
image_output_size *string
|
||||
image_size_source *string
|
||||
image_size_breakdown *map[string]int
|
||||
video_count *int
|
||||
addvideo_count *int
|
||||
video_resolution *string
|
||||
video_duration_seconds *int
|
||||
addvideo_duration_seconds *int
|
||||
cache_ttl_overridden *bool
|
||||
created_at *time.Time
|
||||
clearedFields map[string]struct{}
|
||||
user *int64
|
||||
cleareduser bool
|
||||
api_key *int64
|
||||
clearedapi_key bool
|
||||
account *int64
|
||||
clearedaccount bool
|
||||
group *int64
|
||||
clearedgroup bool
|
||||
subscription *int64
|
||||
clearedsubscription bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*UsageLog, error)
|
||||
predicates []predicate.UsageLog
|
||||
}
|
||||
|
||||
var _ ent.Mutation = (*UsageLogMutation)(nil)
|
||||
@@ -43154,6 +43316,42 @@ func (m *UsageLogMutation) ResetRateMultiplier() {
|
||||
m.addrate_multiplier = nil
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (m *UsageLogMutation) SetLongContextBillingApplied(b bool) {
|
||||
m.long_context_billing_applied = &b
|
||||
}
|
||||
|
||||
// LongContextBillingApplied returns the value of the "long_context_billing_applied" field in the mutation.
|
||||
func (m *UsageLogMutation) LongContextBillingApplied() (r bool, exists bool) {
|
||||
v := m.long_context_billing_applied
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldLongContextBillingApplied returns the old "long_context_billing_applied" field's value of the UsageLog entity.
|
||||
// If the UsageLog 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 *UsageLogMutation) OldLongContextBillingApplied(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldLongContextBillingApplied is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldLongContextBillingApplied requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldLongContextBillingApplied: %w", err)
|
||||
}
|
||||
return oldValue.LongContextBillingApplied, nil
|
||||
}
|
||||
|
||||
// ResetLongContextBillingApplied resets all changes to the "long_context_billing_applied" field.
|
||||
func (m *UsageLogMutation) ResetLongContextBillingApplied() {
|
||||
m.long_context_billing_applied = nil
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (m *UsageLogMutation) SetAccountRateMultiplier(f float64) {
|
||||
m.account_rate_multiplier = &f
|
||||
@@ -44271,7 +44469,7 @@ func (m *UsageLogMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UsageLogMutation) Fields() []string {
|
||||
fields := make([]string, 0, 44)
|
||||
fields := make([]string, 0, 45)
|
||||
if m.user != nil {
|
||||
fields = append(fields, usagelog.FieldUserID)
|
||||
}
|
||||
@@ -44350,6 +44548,9 @@ func (m *UsageLogMutation) Fields() []string {
|
||||
if m.rate_multiplier != nil {
|
||||
fields = append(fields, usagelog.FieldRateMultiplier)
|
||||
}
|
||||
if m.long_context_billing_applied != nil {
|
||||
fields = append(fields, usagelog.FieldLongContextBillingApplied)
|
||||
}
|
||||
if m.account_rate_multiplier != nil {
|
||||
fields = append(fields, usagelog.FieldAccountRateMultiplier)
|
||||
}
|
||||
@@ -44464,6 +44665,8 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.ActualCost()
|
||||
case usagelog.FieldRateMultiplier:
|
||||
return m.RateMultiplier()
|
||||
case usagelog.FieldLongContextBillingApplied:
|
||||
return m.LongContextBillingApplied()
|
||||
case usagelog.FieldAccountRateMultiplier:
|
||||
return m.AccountRateMultiplier()
|
||||
case usagelog.FieldBillingType:
|
||||
@@ -44561,6 +44764,8 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
||||
return m.OldActualCost(ctx)
|
||||
case usagelog.FieldRateMultiplier:
|
||||
return m.OldRateMultiplier(ctx)
|
||||
case usagelog.FieldLongContextBillingApplied:
|
||||
return m.OldLongContextBillingApplied(ctx)
|
||||
case usagelog.FieldAccountRateMultiplier:
|
||||
return m.OldAccountRateMultiplier(ctx)
|
||||
case usagelog.FieldBillingType:
|
||||
@@ -44788,6 +44993,13 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetRateMultiplier(v)
|
||||
return nil
|
||||
case usagelog.FieldLongContextBillingApplied:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetLongContextBillingApplied(v)
|
||||
return nil
|
||||
case usagelog.FieldAccountRateMultiplier:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
@@ -45419,6 +45631,9 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
||||
case usagelog.FieldRateMultiplier:
|
||||
m.ResetRateMultiplier()
|
||||
return nil
|
||||
case usagelog.FieldLongContextBillingApplied:
|
||||
m.ResetLongContextBillingApplied()
|
||||
return nil
|
||||
case usagelog.FieldAccountRateMultiplier:
|
||||
m.ResetAccountRateMultiplier()
|
||||
return nil
|
||||
|
||||
@@ -1044,53 +1044,53 @@ func init() {
|
||||
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
|
||||
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
|
||||
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
|
||||
groupDescClaudeCodeOnly := groupFields[29].Descriptor()
|
||||
groupDescClaudeCodeOnly := groupFields[30].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[33].Descriptor()
|
||||
groupDescModelRoutingEnabled := groupFields[34].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[34].Descriptor()
|
||||
groupDescMcpXMLInject := groupFields[35].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[35].Descriptor()
|
||||
groupDescSupportedModelScopes := groupFields[36].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[36].Descriptor()
|
||||
groupDescSortOrder := groupFields[37].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[37].Descriptor()
|
||||
groupDescAllowMessagesDispatch := groupFields[38].Descriptor()
|
||||
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
|
||||
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[38].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[39].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[39].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[40].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[40].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[41].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[41].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[42].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[42].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[43].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[43].Descriptor()
|
||||
groupDescRpmLimit := groupFields[44].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
@@ -1718,40 +1718,46 @@ func init() {
|
||||
subscriptionplanDescDescription := subscriptionplanFields[2].Descriptor()
|
||||
// subscriptionplan.DefaultDescription holds the default value on creation for the description field.
|
||||
subscriptionplan.DefaultDescription = subscriptionplanDescDescription.Default.(string)
|
||||
// subscriptionplanDescCurrency is the schema descriptor for currency field.
|
||||
subscriptionplanDescCurrency := subscriptionplanFields[5].Descriptor()
|
||||
// subscriptionplan.DefaultCurrency holds the default value on creation for the currency field.
|
||||
subscriptionplan.DefaultCurrency = subscriptionplanDescCurrency.Default.(string)
|
||||
// subscriptionplan.CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
|
||||
subscriptionplan.CurrencyValidator = subscriptionplanDescCurrency.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescValidityDays is the schema descriptor for validity_days field.
|
||||
subscriptionplanDescValidityDays := subscriptionplanFields[5].Descriptor()
|
||||
subscriptionplanDescValidityDays := subscriptionplanFields[6].Descriptor()
|
||||
// subscriptionplan.DefaultValidityDays holds the default value on creation for the validity_days field.
|
||||
subscriptionplan.DefaultValidityDays = subscriptionplanDescValidityDays.Default.(int)
|
||||
// subscriptionplanDescValidityUnit is the schema descriptor for validity_unit field.
|
||||
subscriptionplanDescValidityUnit := subscriptionplanFields[6].Descriptor()
|
||||
subscriptionplanDescValidityUnit := subscriptionplanFields[7].Descriptor()
|
||||
// subscriptionplan.DefaultValidityUnit holds the default value on creation for the validity_unit field.
|
||||
subscriptionplan.DefaultValidityUnit = subscriptionplanDescValidityUnit.Default.(string)
|
||||
// subscriptionplan.ValidityUnitValidator is a validator for the "validity_unit" field. It is called by the builders before save.
|
||||
subscriptionplan.ValidityUnitValidator = subscriptionplanDescValidityUnit.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescFeatures is the schema descriptor for features field.
|
||||
subscriptionplanDescFeatures := subscriptionplanFields[7].Descriptor()
|
||||
subscriptionplanDescFeatures := subscriptionplanFields[8].Descriptor()
|
||||
// subscriptionplan.DefaultFeatures holds the default value on creation for the features field.
|
||||
subscriptionplan.DefaultFeatures = subscriptionplanDescFeatures.Default.(string)
|
||||
// subscriptionplanDescProductName is the schema descriptor for product_name field.
|
||||
subscriptionplanDescProductName := subscriptionplanFields[8].Descriptor()
|
||||
subscriptionplanDescProductName := subscriptionplanFields[9].Descriptor()
|
||||
// subscriptionplan.DefaultProductName holds the default value on creation for the product_name field.
|
||||
subscriptionplan.DefaultProductName = subscriptionplanDescProductName.Default.(string)
|
||||
// subscriptionplan.ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save.
|
||||
subscriptionplan.ProductNameValidator = subscriptionplanDescProductName.Validators[0].(func(string) error)
|
||||
// subscriptionplanDescForSale is the schema descriptor for for_sale field.
|
||||
subscriptionplanDescForSale := subscriptionplanFields[9].Descriptor()
|
||||
subscriptionplanDescForSale := subscriptionplanFields[10].Descriptor()
|
||||
// subscriptionplan.DefaultForSale holds the default value on creation for the for_sale field.
|
||||
subscriptionplan.DefaultForSale = subscriptionplanDescForSale.Default.(bool)
|
||||
// subscriptionplanDescSortOrder is the schema descriptor for sort_order field.
|
||||
subscriptionplanDescSortOrder := subscriptionplanFields[10].Descriptor()
|
||||
subscriptionplanDescSortOrder := subscriptionplanFields[11].Descriptor()
|
||||
// subscriptionplan.DefaultSortOrder holds the default value on creation for the sort_order field.
|
||||
subscriptionplan.DefaultSortOrder = subscriptionplanDescSortOrder.Default.(int)
|
||||
// subscriptionplanDescCreatedAt is the schema descriptor for created_at field.
|
||||
subscriptionplanDescCreatedAt := subscriptionplanFields[11].Descriptor()
|
||||
subscriptionplanDescCreatedAt := subscriptionplanFields[12].Descriptor()
|
||||
// subscriptionplan.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
subscriptionplan.DefaultCreatedAt = subscriptionplanDescCreatedAt.Default.(func() time.Time)
|
||||
// subscriptionplanDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
subscriptionplanDescUpdatedAt := subscriptionplanFields[12].Descriptor()
|
||||
subscriptionplanDescUpdatedAt := subscriptionplanFields[13].Descriptor()
|
||||
// subscriptionplan.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
subscriptionplan.DefaultUpdatedAt = subscriptionplanDescUpdatedAt.Default.(func() time.Time)
|
||||
// subscriptionplan.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
@@ -1940,56 +1946,60 @@ func init() {
|
||||
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
|
||||
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
|
||||
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
|
||||
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
|
||||
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
|
||||
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
|
||||
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
|
||||
// usagelogDescBillingType is the schema descriptor for billing_type field.
|
||||
usagelogDescBillingType := usagelogFields[27].Descriptor()
|
||||
usagelogDescBillingType := usagelogFields[28].Descriptor()
|
||||
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
|
||||
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
|
||||
// usagelogDescStream is the schema descriptor for stream field.
|
||||
usagelogDescStream := usagelogFields[28].Descriptor()
|
||||
usagelogDescStream := usagelogFields[29].Descriptor()
|
||||
// usagelog.DefaultStream holds the default value on creation for the stream field.
|
||||
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
|
||||
// usagelogDescUserAgent is the schema descriptor for user_agent field.
|
||||
usagelogDescUserAgent := usagelogFields[31].Descriptor()
|
||||
usagelogDescUserAgent := usagelogFields[32].Descriptor()
|
||||
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
|
||||
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
|
||||
// usagelogDescIPAddress is the schema descriptor for ip_address field.
|
||||
usagelogDescIPAddress := usagelogFields[32].Descriptor()
|
||||
usagelogDescIPAddress := usagelogFields[33].Descriptor()
|
||||
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
|
||||
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
|
||||
// usagelogDescImageCount is the schema descriptor for image_count field.
|
||||
usagelogDescImageCount := usagelogFields[33].Descriptor()
|
||||
usagelogDescImageCount := usagelogFields[34].Descriptor()
|
||||
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
|
||||
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
|
||||
// usagelogDescImageSize is the schema descriptor for image_size field.
|
||||
usagelogDescImageSize := usagelogFields[34].Descriptor()
|
||||
usagelogDescImageSize := usagelogFields[35].Descriptor()
|
||||
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
|
||||
usagelogDescImageInputSize := usagelogFields[35].Descriptor()
|
||||
usagelogDescImageInputSize := usagelogFields[36].Descriptor()
|
||||
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
|
||||
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
|
||||
usagelogDescImageOutputSize := usagelogFields[36].Descriptor()
|
||||
usagelogDescImageOutputSize := usagelogFields[37].Descriptor()
|
||||
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
|
||||
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
|
||||
usagelogDescImageSizeSource := usagelogFields[37].Descriptor()
|
||||
usagelogDescImageSizeSource := usagelogFields[38].Descriptor()
|
||||
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
|
||||
// usagelogDescVideoCount is the schema descriptor for video_count field.
|
||||
usagelogDescVideoCount := usagelogFields[39].Descriptor()
|
||||
usagelogDescVideoCount := usagelogFields[40].Descriptor()
|
||||
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
|
||||
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
|
||||
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
|
||||
usagelogDescVideoResolution := usagelogFields[40].Descriptor()
|
||||
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
|
||||
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
|
||||
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
|
||||
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[42].Descriptor()
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
|
||||
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
|
||||
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
|
||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||
usagelogDescCreatedAt := usagelogFields[43].Descriptor()
|
||||
usagelogDescCreatedAt := usagelogFields[44].Descriptor()
|
||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||
userMixin := schema.User{}.Mixin()
|
||||
|
||||
@@ -35,7 +35,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini"),
|
||||
Values("openai", "anthropic", "gemini", "grok"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -39,7 +39,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini"),
|
||||
Values("openai", "anthropic", "gemini", "grok"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -142,6 +142,11 @@ func (Group) Fields() []ent.Field {
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("web_search_price_per_call").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"),
|
||||
|
||||
// Claude Code 客户端限制 (added by migration 029)
|
||||
field.Bool("claude_code_only").
|
||||
|
||||
@@ -43,6 +43,9 @@ func (SubscriptionPlan) Fields() []ent.Field {
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("currency").
|
||||
MaxLen(3).
|
||||
Default(""),
|
||||
field.Int("validity_days").
|
||||
Default(30),
|
||||
field.String("validity_unit").
|
||||
|
||||
@@ -100,6 +100,9 @@ func (UsageLog) Fields() []ent.Field {
|
||||
field.Float("rate_multiplier").
|
||||
Default(1).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}),
|
||||
field.Bool("long_context_billing_applied").
|
||||
Default(false).
|
||||
Comment("Whether long-context pricing changed token prices for this request"),
|
||||
|
||||
// account_rate_multiplier: 账号计费倍率快照(NULL 表示按 1.0 处理)
|
||||
field.Float("account_rate_multiplier").
|
||||
|
||||
@@ -27,6 +27,8 @@ type SubscriptionPlan struct {
|
||||
Price float64 `json:"price,omitempty"`
|
||||
// OriginalPrice holds the value of the "original_price" field.
|
||||
OriginalPrice *float64 `json:"original_price,omitempty"`
|
||||
// Currency holds the value of the "currency" field.
|
||||
Currency string `json:"currency,omitempty"`
|
||||
// ValidityDays holds the value of the "validity_days" field.
|
||||
ValidityDays int `json:"validity_days,omitempty"`
|
||||
// ValidityUnit holds the value of the "validity_unit" field.
|
||||
@@ -57,7 +59,7 @@ func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) {
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case subscriptionplan.FieldID, subscriptionplan.FieldGroupID, subscriptionplan.FieldValidityDays, subscriptionplan.FieldSortOrder:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
|
||||
case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldCurrency, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
|
||||
values[i] = new(sql.NullString)
|
||||
case subscriptionplan.FieldCreatedAt, subscriptionplan.FieldUpdatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -113,6 +115,12 @@ func (_m *SubscriptionPlan) assignValues(columns []string, values []any) error {
|
||||
_m.OriginalPrice = new(float64)
|
||||
*_m.OriginalPrice = value.Float64
|
||||
}
|
||||
case subscriptionplan.FieldCurrency:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field currency", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Currency = value.String
|
||||
}
|
||||
case subscriptionplan.FieldValidityDays:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field validity_days", values[i])
|
||||
@@ -214,6 +222,9 @@ func (_m *SubscriptionPlan) String() string {
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("currency=")
|
||||
builder.WriteString(_m.Currency)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("validity_days=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ValidityDays))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -23,6 +23,8 @@ const (
|
||||
FieldPrice = "price"
|
||||
// FieldOriginalPrice holds the string denoting the original_price field in the database.
|
||||
FieldOriginalPrice = "original_price"
|
||||
// FieldCurrency holds the string denoting the currency field in the database.
|
||||
FieldCurrency = "currency"
|
||||
// FieldValidityDays holds the string denoting the validity_days field in the database.
|
||||
FieldValidityDays = "validity_days"
|
||||
// FieldValidityUnit holds the string denoting the validity_unit field in the database.
|
||||
@@ -51,6 +53,7 @@ var Columns = []string{
|
||||
FieldDescription,
|
||||
FieldPrice,
|
||||
FieldOriginalPrice,
|
||||
FieldCurrency,
|
||||
FieldValidityDays,
|
||||
FieldValidityUnit,
|
||||
FieldFeatures,
|
||||
@@ -76,6 +79,10 @@ var (
|
||||
NameValidator func(string) error
|
||||
// DefaultDescription holds the default value on creation for the "description" field.
|
||||
DefaultDescription string
|
||||
// DefaultCurrency holds the default value on creation for the "currency" field.
|
||||
DefaultCurrency string
|
||||
// CurrencyValidator is a validator for the "currency" field. It is called by the builders before save.
|
||||
CurrencyValidator func(string) error
|
||||
// DefaultValidityDays holds the default value on creation for the "validity_days" field.
|
||||
DefaultValidityDays int
|
||||
// DefaultValidityUnit holds the default value on creation for the "validity_unit" field.
|
||||
@@ -133,6 +140,11 @@ func ByOriginalPrice(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldOriginalPrice, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByCurrency orders the results by the currency field.
|
||||
func ByCurrency(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldCurrency, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByValidityDays orders the results by the validity_days field.
|
||||
func ByValidityDays(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldValidityDays, opts...).ToFunc()
|
||||
|
||||
@@ -79,6 +79,11 @@ func OriginalPrice(v float64) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldOriginalPrice, v))
|
||||
}
|
||||
|
||||
// Currency applies equality check predicate on the "currency" field. It's identical to CurrencyEQ.
|
||||
func Currency(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// ValidityDays applies equality check predicate on the "validity_days" field. It's identical to ValidityDaysEQ.
|
||||
func ValidityDays(v int) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
|
||||
@@ -379,6 +384,71 @@ func OriginalPriceNotNil() predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNotNull(FieldOriginalPrice))
|
||||
}
|
||||
|
||||
// CurrencyEQ applies the EQ predicate on the "currency" field.
|
||||
func CurrencyEQ(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyNEQ applies the NEQ predicate on the "currency" field.
|
||||
func CurrencyNEQ(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNEQ(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyIn applies the In predicate on the "currency" field.
|
||||
func CurrencyIn(vs ...string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldIn(FieldCurrency, vs...))
|
||||
}
|
||||
|
||||
// CurrencyNotIn applies the NotIn predicate on the "currency" field.
|
||||
func CurrencyNotIn(vs ...string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldNotIn(FieldCurrency, vs...))
|
||||
}
|
||||
|
||||
// CurrencyGT applies the GT predicate on the "currency" field.
|
||||
func CurrencyGT(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldGT(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyGTE applies the GTE predicate on the "currency" field.
|
||||
func CurrencyGTE(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldGTE(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyLT applies the LT predicate on the "currency" field.
|
||||
func CurrencyLT(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldLT(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyLTE applies the LTE predicate on the "currency" field.
|
||||
func CurrencyLTE(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldLTE(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyContains applies the Contains predicate on the "currency" field.
|
||||
func CurrencyContains(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldContains(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyHasPrefix applies the HasPrefix predicate on the "currency" field.
|
||||
func CurrencyHasPrefix(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldHasPrefix(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyHasSuffix applies the HasSuffix predicate on the "currency" field.
|
||||
func CurrencyHasSuffix(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldHasSuffix(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyEqualFold applies the EqualFold predicate on the "currency" field.
|
||||
func CurrencyEqualFold(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEqualFold(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// CurrencyContainsFold applies the ContainsFold predicate on the "currency" field.
|
||||
func CurrencyContainsFold(v string) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldContainsFold(FieldCurrency, v))
|
||||
}
|
||||
|
||||
// ValidityDaysEQ applies the EQ predicate on the "validity_days" field.
|
||||
func ValidityDaysEQ(v int) predicate.SubscriptionPlan {
|
||||
return predicate.SubscriptionPlan(sql.FieldEQ(FieldValidityDays, v))
|
||||
|
||||
@@ -68,6 +68,20 @@ func (_c *SubscriptionPlanCreate) SetNillableOriginalPrice(v *float64) *Subscrip
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_c *SubscriptionPlanCreate) SetCurrency(v string) *SubscriptionPlanCreate {
|
||||
_c.mutation.SetCurrency(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_c *SubscriptionPlanCreate) SetNillableCurrency(v *string) *SubscriptionPlanCreate {
|
||||
if v != nil {
|
||||
_c.SetCurrency(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_c *SubscriptionPlanCreate) SetValidityDays(v int) *SubscriptionPlanCreate {
|
||||
_c.mutation.SetValidityDays(v)
|
||||
@@ -219,6 +233,10 @@ func (_c *SubscriptionPlanCreate) defaults() {
|
||||
v := subscriptionplan.DefaultDescription
|
||||
_c.mutation.SetDescription(v)
|
||||
}
|
||||
if _, ok := _c.mutation.Currency(); !ok {
|
||||
v := subscriptionplan.DefaultCurrency
|
||||
_c.mutation.SetCurrency(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ValidityDays(); !ok {
|
||||
v := subscriptionplan.DefaultValidityDays
|
||||
_c.mutation.SetValidityDays(v)
|
||||
@@ -272,6 +290,14 @@ func (_c *SubscriptionPlanCreate) check() error {
|
||||
if _, ok := _c.mutation.Price(); !ok {
|
||||
return &ValidationError{Name: "price", err: errors.New(`ent: missing required field "SubscriptionPlan.price"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.Currency(); !ok {
|
||||
return &ValidationError{Name: "currency", err: errors.New(`ent: missing required field "SubscriptionPlan.currency"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ValidityDays(); !ok {
|
||||
return &ValidationError{Name: "validity_days", err: errors.New(`ent: missing required field "SubscriptionPlan.validity_days"`)}
|
||||
}
|
||||
@@ -353,6 +379,10 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre
|
||||
_spec.SetField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64, value)
|
||||
_node.OriginalPrice = &value
|
||||
}
|
||||
if value, ok := _c.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
_node.Currency = value
|
||||
}
|
||||
if value, ok := _c.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
_node.ValidityDays = value
|
||||
@@ -521,6 +551,18 @@ func (u *SubscriptionPlanUpsert) ClearOriginalPrice() *SubscriptionPlanUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsert) SetCurrency(v string) *SubscriptionPlanUpsert {
|
||||
u.Set(subscriptionplan.FieldCurrency, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsert) UpdateCurrency() *SubscriptionPlanUpsert {
|
||||
u.SetExcluded(subscriptionplan.FieldCurrency)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsert) SetValidityDays(v int) *SubscriptionPlanUpsert {
|
||||
u.Set(subscriptionplan.FieldValidityDays, v)
|
||||
@@ -760,6 +802,20 @@ func (u *SubscriptionPlanUpsertOne) ClearOriginalPrice() *SubscriptionPlanUpsert
|
||||
})
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsertOne) SetCurrency(v string) *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.SetCurrency(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsertOne) UpdateCurrency() *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.UpdateCurrency()
|
||||
})
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsertOne) SetValidityDays(v int) *SubscriptionPlanUpsertOne {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
@@ -1181,6 +1237,20 @@ func (u *SubscriptionPlanUpsertBulk) ClearOriginalPrice() *SubscriptionPlanUpser
|
||||
})
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (u *SubscriptionPlanUpsertBulk) SetCurrency(v string) *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.SetCurrency(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCurrency sets the "currency" field to the value that was provided on create.
|
||||
func (u *SubscriptionPlanUpsertBulk) UpdateCurrency() *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
s.UpdateCurrency()
|
||||
})
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (u *SubscriptionPlanUpsertBulk) SetValidityDays(v int) *SubscriptionPlanUpsertBulk {
|
||||
return u.Update(func(s *SubscriptionPlanUpsert) {
|
||||
|
||||
@@ -125,6 +125,20 @@ func (_u *SubscriptionPlanUpdate) ClearOriginalPrice() *SubscriptionPlanUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_u *SubscriptionPlanUpdate) SetCurrency(v string) *SubscriptionPlanUpdate {
|
||||
_u.mutation.SetCurrency(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_u *SubscriptionPlanUpdate) SetNillableCurrency(v *string) *SubscriptionPlanUpdate {
|
||||
if v != nil {
|
||||
_u.SetCurrency(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_u *SubscriptionPlanUpdate) SetValidityDays(v int) *SubscriptionPlanUpdate {
|
||||
_u.mutation.ResetValidityDays()
|
||||
@@ -277,6 +291,11 @@ func (_u *SubscriptionPlanUpdate) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ValidityUnit(); ok {
|
||||
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
|
||||
@@ -329,6 +348,9 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e
|
||||
if _u.mutation.OriginalPriceCleared() {
|
||||
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
}
|
||||
@@ -473,6 +495,20 @@ func (_u *SubscriptionPlanUpdateOne) ClearOriginalPrice() *SubscriptionPlanUpdat
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurrency sets the "currency" field.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetCurrency(v string) *SubscriptionPlanUpdateOne {
|
||||
_u.mutation.SetCurrency(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCurrency sets the "currency" field if the given value is not nil.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetNillableCurrency(v *string) *SubscriptionPlanUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetCurrency(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetValidityDays sets the "validity_days" field.
|
||||
func (_u *SubscriptionPlanUpdateOne) SetValidityDays(v int) *SubscriptionPlanUpdateOne {
|
||||
_u.mutation.ResetValidityDays()
|
||||
@@ -638,6 +674,11 @@ func (_u *SubscriptionPlanUpdateOne) check() error {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.Currency(); ok {
|
||||
if err := subscriptionplan.CurrencyValidator(v); err != nil {
|
||||
return &ValidationError{Name: "currency", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.currency": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ValidityUnit(); ok {
|
||||
if err := subscriptionplan.ValidityUnitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "validity_unit", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.validity_unit": %w`, err)}
|
||||
@@ -707,6 +748,9 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr
|
||||
if _u.mutation.OriginalPriceCleared() {
|
||||
_spec.ClearField(subscriptionplan.FieldOriginalPrice, field.TypeFloat64)
|
||||
}
|
||||
if value, ok := _u.mutation.Currency(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldCurrency, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ValidityDays(); ok {
|
||||
_spec.SetField(subscriptionplan.FieldValidityDays, field.TypeInt, value)
|
||||
}
|
||||
|
||||
+12
-1
@@ -75,6 +75,8 @@ type UsageLog struct {
|
||||
ActualCost float64 `json:"actual_cost,omitempty"`
|
||||
// RateMultiplier holds the value of the "rate_multiplier" field.
|
||||
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
|
||||
// Whether long-context pricing changed token prices for this request
|
||||
LongContextBillingApplied bool `json:"long_context_billing_applied,omitempty"`
|
||||
// AccountRateMultiplier holds the value of the "account_rate_multiplier" field.
|
||||
AccountRateMultiplier *float64 `json:"account_rate_multiplier,omitempty"`
|
||||
// BillingType holds the value of the "billing_type" field.
|
||||
@@ -196,7 +198,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case usagelog.FieldImageSizeBreakdown:
|
||||
values[i] = new([]byte)
|
||||
case usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
values[i] = new(sql.NullBool)
|
||||
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
@@ -391,6 +393,12 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.RateMultiplier = value.Float64
|
||||
}
|
||||
case usagelog.FieldLongContextBillingApplied:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field long_context_billing_applied", values[i])
|
||||
} else if value.Valid {
|
||||
_m.LongContextBillingApplied = value.Bool
|
||||
}
|
||||
case usagelog.FieldAccountRateMultiplier:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field account_rate_multiplier", values[i])
|
||||
@@ -667,6 +675,9 @@ func (_m *UsageLog) String() string {
|
||||
builder.WriteString("rate_multiplier=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("long_context_billing_applied=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.LongContextBillingApplied))
|
||||
builder.WriteString(", ")
|
||||
if v := _m.AccountRateMultiplier; v != nil {
|
||||
builder.WriteString("account_rate_multiplier=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
|
||||
@@ -66,6 +66,8 @@ const (
|
||||
FieldActualCost = "actual_cost"
|
||||
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
|
||||
FieldRateMultiplier = "rate_multiplier"
|
||||
// FieldLongContextBillingApplied holds the string denoting the long_context_billing_applied field in the database.
|
||||
FieldLongContextBillingApplied = "long_context_billing_applied"
|
||||
// FieldAccountRateMultiplier holds the string denoting the account_rate_multiplier field in the database.
|
||||
FieldAccountRateMultiplier = "account_rate_multiplier"
|
||||
// FieldBillingType holds the string denoting the billing_type field in the database.
|
||||
@@ -180,6 +182,7 @@ var Columns = []string{
|
||||
FieldTotalCost,
|
||||
FieldActualCost,
|
||||
FieldRateMultiplier,
|
||||
FieldLongContextBillingApplied,
|
||||
FieldAccountRateMultiplier,
|
||||
FieldBillingType,
|
||||
FieldStream,
|
||||
@@ -251,6 +254,8 @@ var (
|
||||
DefaultActualCost float64
|
||||
// DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field.
|
||||
DefaultRateMultiplier float64
|
||||
// DefaultLongContextBillingApplied holds the default value on creation for the "long_context_billing_applied" field.
|
||||
DefaultLongContextBillingApplied bool
|
||||
// DefaultBillingType holds the default value on creation for the "billing_type" field.
|
||||
DefaultBillingType int8
|
||||
// DefaultStream holds the default value on creation for the "stream" field.
|
||||
@@ -417,6 +422,11 @@ func ByRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRateMultiplier, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByLongContextBillingApplied orders the results by the long_context_billing_applied field.
|
||||
func ByLongContextBillingApplied(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldLongContextBillingApplied, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAccountRateMultiplier orders the results by the account_rate_multiplier field.
|
||||
func ByAccountRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAccountRateMultiplier, opts...).ToFunc()
|
||||
|
||||
@@ -185,6 +185,11 @@ func RateMultiplier(v float64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldRateMultiplier, v))
|
||||
}
|
||||
|
||||
// LongContextBillingApplied applies equality check predicate on the "long_context_billing_applied" field. It's identical to LongContextBillingAppliedEQ.
|
||||
func LongContextBillingApplied(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldLongContextBillingApplied, v))
|
||||
}
|
||||
|
||||
// AccountRateMultiplier applies equality check predicate on the "account_rate_multiplier" field. It's identical to AccountRateMultiplierEQ.
|
||||
func AccountRateMultiplier(v float64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldAccountRateMultiplier, v))
|
||||
@@ -1465,6 +1470,16 @@ func RateMultiplierLTE(v float64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLTE(FieldRateMultiplier, v))
|
||||
}
|
||||
|
||||
// LongContextBillingAppliedEQ applies the EQ predicate on the "long_context_billing_applied" field.
|
||||
func LongContextBillingAppliedEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldLongContextBillingApplied, v))
|
||||
}
|
||||
|
||||
// LongContextBillingAppliedNEQ applies the NEQ predicate on the "long_context_billing_applied" field.
|
||||
func LongContextBillingAppliedNEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldLongContextBillingApplied, v))
|
||||
}
|
||||
|
||||
// AccountRateMultiplierEQ applies the EQ predicate on the "account_rate_multiplier" field.
|
||||
func AccountRateMultiplierEQ(v float64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldAccountRateMultiplier, v))
|
||||
|
||||
@@ -351,6 +351,20 @@ func (_c *UsageLogCreate) SetNillableRateMultiplier(v *float64) *UsageLogCreate
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (_c *UsageLogCreate) SetLongContextBillingApplied(v bool) *UsageLogCreate {
|
||||
_c.mutation.SetLongContextBillingApplied(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableLongContextBillingApplied sets the "long_context_billing_applied" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableLongContextBillingApplied(v *bool) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetLongContextBillingApplied(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (_c *UsageLogCreate) SetAccountRateMultiplier(v float64) *UsageLogCreate {
|
||||
_c.mutation.SetAccountRateMultiplier(v)
|
||||
@@ -707,6 +721,10 @@ func (_c *UsageLogCreate) defaults() {
|
||||
v := usagelog.DefaultRateMultiplier
|
||||
_c.mutation.SetRateMultiplier(v)
|
||||
}
|
||||
if _, ok := _c.mutation.LongContextBillingApplied(); !ok {
|
||||
v := usagelog.DefaultLongContextBillingApplied
|
||||
_c.mutation.SetLongContextBillingApplied(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BillingType(); !ok {
|
||||
v := usagelog.DefaultBillingType
|
||||
_c.mutation.SetBillingType(v)
|
||||
@@ -824,6 +842,9 @@ func (_c *UsageLogCreate) check() error {
|
||||
if _, ok := _c.mutation.RateMultiplier(); !ok {
|
||||
return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "UsageLog.rate_multiplier"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.LongContextBillingApplied(); !ok {
|
||||
return &ValidationError{Name: "long_context_billing_applied", err: errors.New(`ent: missing required field "UsageLog.long_context_billing_applied"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BillingType(); !ok {
|
||||
return &ValidationError{Name: "billing_type", err: errors.New(`ent: missing required field "UsageLog.billing_type"`)}
|
||||
}
|
||||
@@ -997,6 +1018,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(usagelog.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
_node.RateMultiplier = value
|
||||
}
|
||||
if value, ok := _c.mutation.LongContextBillingApplied(); ok {
|
||||
_spec.SetField(usagelog.FieldLongContextBillingApplied, field.TypeBool, value)
|
||||
_node.LongContextBillingApplied = value
|
||||
}
|
||||
if value, ok := _c.mutation.AccountRateMultiplier(); ok {
|
||||
_spec.SetField(usagelog.FieldAccountRateMultiplier, field.TypeFloat64, value)
|
||||
_node.AccountRateMultiplier = &value
|
||||
@@ -1650,6 +1675,18 @@ func (u *UsageLogUpsert) AddRateMultiplier(v float64) *UsageLogUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (u *UsageLogUpsert) SetLongContextBillingApplied(v bool) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldLongContextBillingApplied, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateLongContextBillingApplied sets the "long_context_billing_applied" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateLongContextBillingApplied() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldLongContextBillingApplied)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (u *UsageLogUpsert) SetAccountRateMultiplier(v float64) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldAccountRateMultiplier, v)
|
||||
@@ -2531,6 +2568,20 @@ func (u *UsageLogUpsertOne) UpdateRateMultiplier() *UsageLogUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (u *UsageLogUpsertOne) SetLongContextBillingApplied(v bool) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetLongContextBillingApplied(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLongContextBillingApplied sets the "long_context_billing_applied" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateLongContextBillingApplied() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateLongContextBillingApplied()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (u *UsageLogUpsertOne) SetAccountRateMultiplier(v float64) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
@@ -3631,6 +3682,20 @@ func (u *UsageLogUpsertBulk) UpdateRateMultiplier() *UsageLogUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (u *UsageLogUpsertBulk) SetLongContextBillingApplied(v bool) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetLongContextBillingApplied(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateLongContextBillingApplied sets the "long_context_billing_applied" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateLongContextBillingApplied() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateLongContextBillingApplied()
|
||||
})
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (u *UsageLogUpsertBulk) SetAccountRateMultiplier(v float64) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
|
||||
@@ -542,6 +542,20 @@ func (_u *UsageLogUpdate) AddRateMultiplier(v float64) *UsageLogUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (_u *UsageLogUpdate) SetLongContextBillingApplied(v bool) *UsageLogUpdate {
|
||||
_u.mutation.SetLongContextBillingApplied(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableLongContextBillingApplied sets the "long_context_billing_applied" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableLongContextBillingApplied(v *bool) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetLongContextBillingApplied(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (_u *UsageLogUpdate) SetAccountRateMultiplier(v float64) *UsageLogUpdate {
|
||||
_u.mutation.ResetAccountRateMultiplier()
|
||||
@@ -1199,6 +1213,9 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
|
||||
_spec.AddField(usagelog.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.LongContextBillingApplied(); ok {
|
||||
_spec.SetField(usagelog.FieldLongContextBillingApplied, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AccountRateMultiplier(); ok {
|
||||
_spec.SetField(usagelog.FieldAccountRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
@@ -1982,6 +1999,20 @@ func (_u *UsageLogUpdateOne) AddRateMultiplier(v float64) *UsageLogUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetLongContextBillingApplied sets the "long_context_billing_applied" field.
|
||||
func (_u *UsageLogUpdateOne) SetLongContextBillingApplied(v bool) *UsageLogUpdateOne {
|
||||
_u.mutation.SetLongContextBillingApplied(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableLongContextBillingApplied sets the "long_context_billing_applied" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableLongContextBillingApplied(v *bool) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetLongContextBillingApplied(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
|
||||
func (_u *UsageLogUpdateOne) SetAccountRateMultiplier(v float64) *UsageLogUpdateOne {
|
||||
_u.mutation.ResetAccountRateMultiplier()
|
||||
@@ -2669,6 +2700,9 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
|
||||
if value, ok := _u.mutation.AddedRateMultiplier(); ok {
|
||||
_spec.AddField(usagelog.FieldRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.LongContextBillingApplied(); ok {
|
||||
_spec.SetField(usagelog.FieldLongContextBillingApplied, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AccountRateMultiplier(); ok {
|
||||
_spec.SetField(usagelog.FieldAccountRateMultiplier, field.TypeFloat64, value)
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,6 +44,7 @@ require (
|
||||
go.uber.org/zap v1.24.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
@@ -176,7 +177,6 @@ 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/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
|
||||
@@ -166,6 +166,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -220,6 +222,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -253,6 +257,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -282,6 +288,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
@@ -314,6 +322,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -94,6 +95,7 @@ type Config struct {
|
||||
Update UpdateConfig `mapstructure:"update"`
|
||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||
BatchImage BatchImageConfig `mapstructure:"batch_image"`
|
||||
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -226,6 +228,33 @@ type BatchImageConfig struct {
|
||||
VertexGCSBaseURL string `mapstructure:"vertex_gcs_base_url"`
|
||||
}
|
||||
|
||||
// ImageStorageConfig 配置异步图片任务结果上传的 S3 兼容对象存储。
|
||||
// Enabled 同时作为异步图片任务功能的总开关:未启用或未配置完整凭证时,
|
||||
// 异步生图接口整体禁用,避免把上游返回的大 base64 结果塞进 Redis。
|
||||
type ImageStorageConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Endpoint string `mapstructure:"endpoint"` // e.g. https://<account_id>.r2.cloudflarestorage.com
|
||||
Region string `mapstructure:"region"` // R2 用 "auto"
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
AccessKeyID string `mapstructure:"access_key_id"`
|
||||
SecretAccessKey string `mapstructure:"secret_access_key"`
|
||||
Prefix string `mapstructure:"prefix"` // S3 key 前缀,如 "images/"
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"` // MinIO/路径风格桶
|
||||
PublicBaseURL string `mapstructure:"public_base_url"` // 配了则返回 public_base_url/key 直链;否则 presigned
|
||||
PresignExpiry int `mapstructure:"presign_expiry_hours"` // public_base_url 为空时的 presigned 过期时长(小时)
|
||||
MaxDownloadByte int64 `mapstructure:"max_download_bytes"` // 下载上游 url 图片的字节上限
|
||||
}
|
||||
|
||||
// IsConfigured 检查对象存储必要字段是否已配置
|
||||
func (c *ImageStorageConfig) IsConfigured() bool {
|
||||
return c.Bucket != "" && c.AccessKeyID != "" && c.SecretAccessKey != ""
|
||||
}
|
||||
|
||||
// Active 返回异步图片任务是否可用:开关打开且凭证齐全
|
||||
func (c *ImageStorageConfig) Active() bool {
|
||||
return c.Enabled && c.IsConfigured()
|
||||
}
|
||||
|
||||
type LinuxDoConnectConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ClientID string `mapstructure:"client_id"`
|
||||
@@ -580,6 +609,18 @@ type TokenRefreshConfig struct {
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
// 重试退避基础时间(秒)
|
||||
RetryBackoffSeconds int `mapstructure:"retry_backoff_seconds"`
|
||||
// 每次从数据库读取的候选账号上限
|
||||
CandidatePageSize int `mapstructure:"candidate_page_size"`
|
||||
// 每个平台允许的并发刷新数
|
||||
ProviderConcurrency int `mapstructure:"provider_concurrency"`
|
||||
// 每个平台、每个进程允许的刷新请求速率
|
||||
ProviderQPS int `mapstructure:"provider_qps"`
|
||||
// 一个周期内连续临时失败达到此值后停止该平台
|
||||
ProviderFailureThreshold int `mapstructure:"provider_failure_threshold"`
|
||||
// 单次上游刷新尝试的超时(秒)
|
||||
AttemptTimeoutSeconds int `mapstructure:"attempt_timeout_seconds"`
|
||||
// 单个后台刷新周期的总超时(秒)
|
||||
CycleTimeoutSeconds int `mapstructure:"cycle_timeout_seconds"`
|
||||
}
|
||||
|
||||
type PricingConfig struct {
|
||||
@@ -601,6 +642,7 @@ type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug/release
|
||||
EnableServerTiming bool `mapstructure:"enable_server_timing"` // Admin UI Server-Timing response header
|
||||
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接
|
||||
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
|
||||
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
|
||||
@@ -747,6 +789,11 @@ type GatewayConfig struct {
|
||||
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
|
||||
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
|
||||
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
|
||||
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
|
||||
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
|
||||
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
|
||||
// 0 表示回退到 OpenAIFirstOutputTimeoutSeconds。
|
||||
OpenAIHighEffortFirstOutputTimeoutSeconds int `mapstructure:"openai_high_effort_first_output_timeout_seconds"`
|
||||
// 请求体最大字节数,用于网关请求体大小限制
|
||||
MaxBodySize int64 `mapstructure:"max_body_size"`
|
||||
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
|
||||
@@ -818,6 +865,8 @@ type GatewayConfig struct {
|
||||
ImageStreamDataIntervalTimeout int `mapstructure:"image_stream_data_interval_timeout"`
|
||||
// ImageStreamKeepaliveInterval: 图片流式 keepalive 间隔(秒),0表示禁用
|
||||
ImageStreamKeepaliveInterval int `mapstructure:"image_stream_keepalive_interval"`
|
||||
// ImageNonstreamKeepaliveInterval: 图片非流式 JSON keepalive 间隔(秒),0表示禁用
|
||||
ImageNonstreamKeepaliveInterval int `mapstructure:"image_nonstream_keepalive_interval"`
|
||||
// MaxLineSize: 上游 SSE 单行最大字节数(0使用默认值)
|
||||
MaxLineSize int `mapstructure:"max_line_size"`
|
||||
|
||||
@@ -916,6 +965,9 @@ func (c *UserMessageQueueConfig) GetEffectiveMode() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultOpenAIWSClientFirstMessageTimeoutSeconds preserves the legacy ingress deadline.
|
||||
const DefaultOpenAIWSClientFirstMessageTimeoutSeconds = 30
|
||||
|
||||
// GatewayOpenAIWSConfig OpenAI Responses WebSocket 配置。
|
||||
// 注意:默认全局开启;如需回滚可使用 force_http 或关闭 enabled。
|
||||
type GatewayOpenAIWSConfig struct {
|
||||
@@ -923,6 +975,15 @@ type GatewayOpenAIWSConfig struct {
|
||||
ModeRouterV2Enabled bool `mapstructure:"mode_router_v2_enabled"`
|
||||
// IngressModeDefault: ingress 默认模式(off/ctx_pool/passthrough/http_bridge)
|
||||
IngressModeDefault string `mapstructure:"ingress_mode_default"`
|
||||
// ClientFirstMessageTimeoutSeconds bounds the total time to read and decompress
|
||||
// the first client response.create message after the WebSocket upgrade.
|
||||
ClientFirstMessageTimeoutSeconds int `mapstructure:"client_first_message_timeout_seconds"`
|
||||
// IngressInterTurnIdleTimeoutSeconds bounds the time a client may remain idle
|
||||
// between completed ingress turns. Zero disables this protection.
|
||||
IngressInterTurnIdleTimeoutSeconds int `mapstructure:"ingress_inter_turn_idle_timeout_seconds"`
|
||||
// MaxIngressConnectionsPerAPIKey bounds live client WebSocket ingress sessions
|
||||
// per API key across all instances. Zero disables this protection.
|
||||
MaxIngressConnectionsPerAPIKey int `mapstructure:"max_ingress_connections_per_api_key"`
|
||||
// Enabled: 全局总开关(默认 true)
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
// OAuthEnabled: 是否允许 OpenAI OAuth 账号使用 WS
|
||||
@@ -1020,11 +1081,35 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
|
||||
Reset float64 `mapstructure:"reset"`
|
||||
// QuotaHeadroom 倾向 7d 剩余额度更健康的账号;默认 0(关闭,不改变原有行为)。
|
||||
QuotaHeadroom float64 `mapstructure:"quota_headroom"`
|
||||
// UpstreamCost 倾向上游声明倍率更低的账号;默认 0(关闭,不改变原有行为)。
|
||||
UpstreamCost float64 `mapstructure:"upstream_cost"`
|
||||
// PreviousResponse/SessionSticky 仅在开启 OpenAI 高级调度的粘性加权时生效。
|
||||
PreviousResponse float64 `mapstructure:"previous_response"`
|
||||
SessionSticky float64 `mapstructure:"session_sticky"`
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) BaseWeightSum() float64 {
|
||||
return w.Priority + w.Load + w.Queue + w.ErrorRate + w.TTFT + w.Reset + w.QuotaHeadroom + w.UpstreamCost
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) TotalWeightSum() float64 {
|
||||
return w.BaseWeightSum() + w.PreviousResponse + w.SessionSticky
|
||||
}
|
||||
|
||||
func (w GatewayOpenAIWSSchedulerScoreWeights) IsValid() bool {
|
||||
for _, weight := range []float64{
|
||||
w.Priority, w.Load, w.Queue, w.ErrorRate, w.TTFT, w.Reset,
|
||||
w.QuotaHeadroom, w.UpstreamCost, w.PreviousResponse, w.SessionSticky,
|
||||
} {
|
||||
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
baseSum := w.BaseWeightSum()
|
||||
return baseSum > 0 && !math.IsNaN(baseSum) && !math.IsInf(baseSum, 0) &&
|
||||
!math.IsNaN(w.TotalWeightSum()) && !math.IsInf(w.TotalWeightSum(), 0)
|
||||
}
|
||||
|
||||
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
|
||||
type GatewayOpenAISchedulerConfig struct {
|
||||
// StickyEscapeEnabled: 是否允许 session_hash sticky 在账号健康度劣化时临时逃逸
|
||||
@@ -1453,6 +1538,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
// 环境变量支持
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
if err := viper.BindEnv("server.enable_server_timing", "ENABLE_SERVER_TIMING"); err != nil {
|
||||
return nil, fmt.Errorf("bind ENABLE_SERVER_TIMING: %w", err)
|
||||
}
|
||||
|
||||
// 默认值
|
||||
setDefaults()
|
||||
@@ -1608,6 +1696,7 @@ func setDefaults() {
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("server.port", 8080)
|
||||
viper.SetDefault("server.mode", "release")
|
||||
viper.SetDefault("server.enable_server_timing", false)
|
||||
viper.SetDefault("server.frontend_url", "")
|
||||
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
|
||||
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
|
||||
@@ -1830,6 +1919,14 @@ func setDefaults() {
|
||||
viper.SetDefault("batch_image.vertex_batch_prediction_base_url", "")
|
||||
viper.SetDefault("batch_image.vertex_gcs_base_url", "")
|
||||
|
||||
// Image storage (async image task result offload to S3-compatible object storage)
|
||||
viper.SetDefault("image_storage.enabled", false)
|
||||
viper.SetDefault("image_storage.region", "auto")
|
||||
viper.SetDefault("image_storage.prefix", "images/")
|
||||
viper.SetDefault("image_storage.force_path_style", false)
|
||||
viper.SetDefault("image_storage.presign_expiry_hours", 24)
|
||||
viper.SetDefault("image_storage.max_download_bytes", 33554432)
|
||||
|
||||
// Ops (vNext)
|
||||
viper.SetDefault("ops.enabled", true)
|
||||
viper.SetDefault("ops.use_preaggregated_tables", true)
|
||||
@@ -1931,6 +2028,8 @@ func setDefaults() {
|
||||
// Gateway
|
||||
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
|
||||
viper.SetDefault("gateway.openai_response_header_timeout", 0)
|
||||
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
|
||||
viper.SetDefault("gateway.log_upstream_error_body", true)
|
||||
viper.SetDefault("gateway.log_upstream_error_body_max_bytes", 2048)
|
||||
viper.SetDefault("gateway.inject_beta_for_apikey", false)
|
||||
@@ -1945,6 +2044,9 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
|
||||
viper.SetDefault("gateway.openai_ws.ingress_mode_default", "ctx_pool")
|
||||
viper.SetDefault("gateway.openai_ws.client_first_message_timeout_seconds", DefaultOpenAIWSClientFirstMessageTimeoutSeconds)
|
||||
viper.SetDefault("gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds", 300)
|
||||
viper.SetDefault("gateway.openai_ws.max_ingress_connections_per_api_key", 64)
|
||||
viper.SetDefault("gateway.openai_ws.oauth_enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.apikey_enabled", true)
|
||||
viper.SetDefault("gateway.openai_ws.force_http", false)
|
||||
@@ -1992,6 +2094,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.quota_headroom", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.upstream_cost", 0.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.previous_response", 5.0)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.session_sticky", 3.0)
|
||||
// OpenAI HTTP upstream protocol strategy
|
||||
@@ -2024,6 +2127,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.stream_keepalive_interval", 10)
|
||||
viper.SetDefault("gateway.image_stream_data_interval_timeout", 900)
|
||||
viper.SetDefault("gateway.image_stream_keepalive_interval", 10)
|
||||
viper.SetDefault("gateway.image_nonstream_keepalive_interval", 0)
|
||||
viper.SetDefault("gateway.max_line_size", 500*1024*1024)
|
||||
viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3)
|
||||
viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 120*time.Second)
|
||||
@@ -2082,6 +2186,12 @@ func setDefaults() {
|
||||
viper.SetDefault("token_refresh.refresh_before_expiry_hours", 0.5) // 提前30分钟刷新(适配Google 1小时token)
|
||||
viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次
|
||||
viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒
|
||||
viper.SetDefault("token_refresh.candidate_page_size", 200)
|
||||
viper.SetDefault("token_refresh.provider_concurrency", 4)
|
||||
viper.SetDefault("token_refresh.provider_qps", 2)
|
||||
viper.SetDefault("token_refresh.provider_failure_threshold", 3)
|
||||
viper.SetDefault("token_refresh.attempt_timeout_seconds", 15)
|
||||
viper.SetDefault("token_refresh.cycle_timeout_seconds", 240)
|
||||
|
||||
// Gemini OAuth - configure via environment variables or config file
|
||||
// GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET
|
||||
@@ -2635,6 +2745,14 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
|
||||
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
|
||||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
|
||||
}
|
||||
if c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 1800 ||
|
||||
(c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 30) {
|
||||
return fmt.Errorf("gateway.openai_high_effort_first_output_timeout_seconds must be 0 or between 30-1800 seconds")
|
||||
}
|
||||
if strings.TrimSpace(c.Gateway.ConnectionPoolIsolation) != "" {
|
||||
switch c.Gateway.ConnectionPoolIsolation {
|
||||
case ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy:
|
||||
@@ -2710,6 +2828,13 @@ func (c *Config) Validate() error {
|
||||
(c.Gateway.ImageStreamKeepaliveInterval < 5 || c.Gateway.ImageStreamKeepaliveInterval > 60) {
|
||||
return fmt.Errorf("gateway.image_stream_keepalive_interval must be 0 or between 5-60 seconds")
|
||||
}
|
||||
if c.Gateway.ImageNonstreamKeepaliveInterval < 0 {
|
||||
return fmt.Errorf("gateway.image_nonstream_keepalive_interval must be non-negative")
|
||||
}
|
||||
if c.Gateway.ImageNonstreamKeepaliveInterval != 0 &&
|
||||
(c.Gateway.ImageNonstreamKeepaliveInterval < 5 || c.Gateway.ImageNonstreamKeepaliveInterval > 60) {
|
||||
return fmt.Errorf("gateway.image_nonstream_keepalive_interval must be 0 or between 5-60 seconds")
|
||||
}
|
||||
// 兼容旧键 sticky_previous_response_ttl_seconds
|
||||
if c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
|
||||
c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds
|
||||
@@ -2717,6 +2842,15 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIWS.MaxConnsPerAccount <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.max_conns_per_account must be positive")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.client_first_message_timeout_seconds must be positive")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.max_ingress_connections_per_api_key must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.MinIdlePerAccount < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.min_idle_per_account must be non-negative")
|
||||
}
|
||||
@@ -2827,25 +2961,26 @@ func (c *Config) Validate() error {
|
||||
if c.Gateway.OpenAIHTTP2.FallbackTTLSeconds < 0 {
|
||||
return fmt.Errorf("gateway.openai_http2.fallback_ttl_seconds must be non-negative")
|
||||
}
|
||||
if c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse < 0 ||
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.SessionSticky < 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative")
|
||||
weights := c.Gateway.OpenAIWS.SchedulerScoreWeights
|
||||
for _, weight := range []float64{
|
||||
weights.Priority, weights.Load, weights.Queue, weights.ErrorRate, weights.TTFT,
|
||||
weights.Reset, weights.QuotaHeadroom, weights.UpstreamCost,
|
||||
weights.PreviousResponse, weights.SessionSticky,
|
||||
} {
|
||||
if weight < 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite")
|
||||
}
|
||||
}
|
||||
weightSum := c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Queue +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT +
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom
|
||||
weightSum := weights.BaseWeightSum()
|
||||
if weightSum <= 0 {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights must not all be zero")
|
||||
}
|
||||
if math.IsNaN(weightSum) || math.IsInf(weightSum, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights base-weight sum must be finite")
|
||||
}
|
||||
if totalWeightSum := weights.TotalWeightSum(); math.IsNaN(totalWeightSum) || math.IsInf(totalWeightSum, 0) {
|
||||
return fmt.Errorf("gateway.openai_ws.scheduler_score_weights total-weight sum must be finite")
|
||||
}
|
||||
if c.Gateway.OpenAIScheduler.StickyEscapeTTFTMs <= 0 {
|
||||
return fmt.Errorf("gateway.openai_scheduler.sticky_escape_ttft_ms must be positive")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,6 +18,23 @@ func resetViperWithJWTSecret(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
}
|
||||
|
||||
func TestLoadServerTimingConfig(t *testing.T) {
|
||||
t.Run("disabled by default", func(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.False(t, cfg.Server.EnableServerTiming)
|
||||
})
|
||||
|
||||
t.Run("enabled by exact environment variable", func(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("ENABLE_SERVER_TIMING", "true")
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.Server.EnableServerTiming)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
@@ -81,6 +99,34 @@ func TestLoadDefaultSchedulingConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIFirstOutputTimeoutsDisabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds)
|
||||
require.Zero(t, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadOpenAIFirstOutputTimeoutsFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_FIRST_OUTPUT_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("GATEWAY_OPENAI_HIGH_EFFORT_FIRST_OUTPUT_TIMEOUT_SECONDS", "240")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 90, cfg.Gateway.OpenAIFirstOutputTimeoutSeconds)
|
||||
require.Equal(t, 240, cfg.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestValidateOpenAIFirstOutputTimeoutMinimum(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
cfg.Gateway.OpenAIFirstOutputTimeoutSeconds = 30
|
||||
require.NoError(t, cfg.Validate())
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -170,6 +216,9 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom != 0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost != 0 {
|
||||
t.Fatalf("Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = %v, want 0", cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost)
|
||||
}
|
||||
if !cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
|
||||
t.Fatalf("Gateway.OpenAIWS.StoreDisabledForceNewConn = false, want true")
|
||||
}
|
||||
@@ -182,6 +231,28 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
|
||||
if cfg.Gateway.OpenAIWS.IngressModeDefault != "ctx_pool" {
|
||||
t.Fatalf("Gateway.OpenAIWS.IngressModeDefault = %q, want %q", cfg.Gateway.OpenAIWS.IngressModeDefault, "ctx_pool")
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds != DefaultOpenAIWSClientFirstMessageTimeoutSeconds {
|
||||
t.Fatalf(
|
||||
"Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = %d, want %d",
|
||||
cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds,
|
||||
DefaultOpenAIWSClientFirstMessageTimeoutSeconds,
|
||||
)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds != 300 {
|
||||
t.Fatalf("Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = %d, want 300", cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds)
|
||||
}
|
||||
if cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey != 64 {
|
||||
t.Fatalf("Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = %d, want 64", cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_WS_CLIENT_FIRST_MESSAGE_TIMEOUT_SECONDS", "120")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
|
||||
@@ -236,6 +307,15 @@ func TestLoadOpenAIResponseHeaderTimeoutFromEnv(t *testing.T) {
|
||||
require.Equal(t, 1800, cfg.Gateway.OpenAIResponseHeaderTimeout)
|
||||
}
|
||||
|
||||
func TestLoadImageNonstreamKeepaliveFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_IMAGE_NONSTREAM_KEEPALIVE_INTERVAL", "15")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 15, cfg.Gateway.ImageNonstreamKeepaliveInterval)
|
||||
}
|
||||
|
||||
func TestLoadOpenAIWSStickyTTLCompatibility(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_WS_STICKY_RESPONSE_ID_TTL_SECONDS", "0")
|
||||
@@ -1316,6 +1396,16 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIResponseHeaderTimeout = -1 },
|
||||
wantErr: "gateway.openai_response_header_timeout",
|
||||
},
|
||||
{
|
||||
name: "gateway openai first output timeout below minimum",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIFirstOutputTimeoutSeconds = 29 },
|
||||
wantErr: "gateway.openai_first_output_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "gateway openai high effort first output timeout too large",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds = 1801 },
|
||||
wantErr: "gateway.openai_high_effort_first_output_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "gateway max idle conns",
|
||||
mutate: func(c *Config) { c.Gateway.MaxIdleConns = 0 },
|
||||
@@ -1406,6 +1496,16 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = -1 },
|
||||
wantErr: "gateway.image_stream_keepalive_interval must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "gateway image nonstream keepalive range",
|
||||
mutate: func(c *Config) { c.Gateway.ImageNonstreamKeepaliveInterval = 4 },
|
||||
wantErr: "gateway.image_nonstream_keepalive_interval",
|
||||
},
|
||||
{
|
||||
name: "gateway image nonstream keepalive negative",
|
||||
mutate: func(c *Config) { c.Gateway.ImageNonstreamKeepaliveInterval = -1 },
|
||||
wantErr: "gateway.image_nonstream_keepalive_interval must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "gateway image stream data interval range",
|
||||
mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = 30 },
|
||||
@@ -1640,6 +1740,26 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxConnsPerAccount = 0 },
|
||||
wantErr: "gateway.openai_ws.max_conns_per_account",
|
||||
},
|
||||
{
|
||||
name: "client_first_message_timeout_seconds 必须为正数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = 0 },
|
||||
wantErr: "gateway.openai_ws.client_first_message_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "client_first_message_timeout_seconds 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds = -1 },
|
||||
wantErr: "gateway.openai_ws.client_first_message_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "ingress_inter_turn_idle_timeout_seconds 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = -1 },
|
||||
wantErr: "gateway.openai_ws.ingress_inter_turn_idle_timeout_seconds",
|
||||
},
|
||||
{
|
||||
name: "max_ingress_connections_per_api_key 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey = -1 },
|
||||
wantErr: "gateway.openai_ws.max_ingress_connections_per_api_key",
|
||||
},
|
||||
{
|
||||
name: "min_idle_per_account 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.MinIdlePerAccount = -1 },
|
||||
@@ -1750,6 +1870,42 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.QuotaHeadroom = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights upstream_cost 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights reset 不能为负数",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = -0.1 },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能为 NaN",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.NaN() },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能为 Inf",
|
||||
mutate: func(c *Config) { c.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = math.Inf(1) },
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights.* must be non-negative and finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 总和不能溢出",
|
||||
mutate: func(c *Config) {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Load = math.MaxFloat64
|
||||
},
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights base-weight sum must be finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 含 sticky 总和不能溢出",
|
||||
mutate: func(c *Config) {
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = math.MaxFloat64
|
||||
c.Gateway.OpenAIWS.SchedulerScoreWeights.PreviousResponse = math.MaxFloat64
|
||||
},
|
||||
wantErr: "gateway.openai_ws.scheduler_score_weights total-weight sum must be finite",
|
||||
},
|
||||
{
|
||||
name: "scheduler_score_weights 不能全为 0",
|
||||
mutate: func(c *Config) {
|
||||
@@ -1801,6 +1957,30 @@ func TestValidateConfig_OpenAIWSRules(t *testing.T) {
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("upstream_cost 可作为唯一有效调度权重", func(t *testing.T) {
|
||||
cfg := buildValid(t)
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.UpstreamCost = 0.1
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
|
||||
t.Run("reset 可作为唯一有效调度权重", func(t *testing.T) {
|
||||
cfg := buildValid(t)
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Priority = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Load = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Queue = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.ErrorRate = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.TTFT = 0
|
||||
cfg.Gateway.OpenAIWS.SchedulerScoreWeights.Reset = 0.1
|
||||
|
||||
require.NoError(t, cfg.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateConfig_AutoScaleDisabledIgnoreAutoScaleFields(t *testing.T) {
|
||||
@@ -1964,6 +2144,9 @@ func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) {
|
||||
if cfg.Gateway.ImageStreamKeepaliveInterval != 10 {
|
||||
t.Fatalf("image_stream_keepalive_interval = %d, want 10", cfg.Gateway.ImageStreamKeepaliveInterval)
|
||||
}
|
||||
if cfg.Gateway.ImageNonstreamKeepaliveInterval != 0 {
|
||||
t.Fatalf("image_nonstream_keepalive_interval = %d, want 0", cfg.Gateway.ImageNonstreamKeepaliveInterval)
|
||||
}
|
||||
if cfg.Gateway.ImageConcurrency.Enabled {
|
||||
t.Fatalf("image_concurrency.enabled = true, want false")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeCodexImportEntryAcceptsAgentIdentityAuthJSON(t *testing.T) {
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
privateKeyBase64 := base64.StdEncoding.EncodeToString(der)
|
||||
|
||||
item, err := normalizeCodexImportEntry(codexImportEntry{
|
||||
Index: 1,
|
||||
Value: map[string]any{
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": map[string]any{
|
||||
"agent_runtime_id": "runtime-import",
|
||||
"agent_private_key": privateKeyBase64,
|
||||
"account_id": "account-import",
|
||||
"chatgpt_user_id": "user-import",
|
||||
"email": "agent@example.invalid",
|
||||
"plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.True(t, item.IsAgentIdentity)
|
||||
require.Equal(t, service.OpenAIAuthModeAgentIdentity, item.Credentials["auth_mode"])
|
||||
require.Equal(t, "runtime-import", item.Credentials["agent_runtime_id"])
|
||||
require.Equal(t, privateKeyBase64, item.Credentials["agent_private_key"])
|
||||
require.Equal(t, "account-import", item.Credentials["chatgpt_account_id"])
|
||||
require.Equal(t, "user-import", item.Credentials["chatgpt_user_id"])
|
||||
require.NotContains(t, item.Credentials, "access_token")
|
||||
require.NotContains(t, item.Credentials, "refresh_token")
|
||||
require.NotEmpty(t, item.WarningTexts)
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsCreatesAgentIdentityWithoutOAuthExpiry(t *testing.T) {
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
result, err := handler.importCodexSessions(context.Background(), CodexSessionImportRequest{
|
||||
SkipDefaultGroupBind: boolPtr(true),
|
||||
}, []codexImportEntry{{
|
||||
Index: 1,
|
||||
Value: map[string]any{
|
||||
"auth_mode": "agentIdentity",
|
||||
"agent_identity": map[string]any{
|
||||
"agent_runtime_id": "runtime-import",
|
||||
"agent_private_key": base64.StdEncoding.EncodeToString(der),
|
||||
"task_id": "task-import",
|
||||
"account_id": "account-import",
|
||||
"chatgpt_user_id": "user-import",
|
||||
},
|
||||
},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, result.Created)
|
||||
require.Zero(t, result.Failed)
|
||||
require.Len(t, svc.createdAccounts, 1)
|
||||
created := svc.createdAccounts[0]
|
||||
require.Nil(t, created.ExpiresAt)
|
||||
require.Nil(t, created.AutoPauseOnExpired)
|
||||
require.Equal(t, service.OpenAIAuthModeAgentIdentity, created.Credentials["auth_mode"])
|
||||
require.NotContains(t, created.Credentials, "access_token")
|
||||
require.NotContains(t, created.Credentials, "refresh_token")
|
||||
}
|
||||
@@ -72,20 +72,25 @@ type codexImportEntry struct {
|
||||
}
|
||||
|
||||
type codexImportAccount struct {
|
||||
Name string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
Email string
|
||||
AccountID string
|
||||
UserID string
|
||||
PlanType string
|
||||
Organization string
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
TokenExpiresAt *time.Time
|
||||
IdentityKeys []string
|
||||
WarningTexts []string
|
||||
Name string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
IDToken string
|
||||
Email string
|
||||
AccountID string
|
||||
UserID string
|
||||
PlanType string
|
||||
Organization string
|
||||
AgentRuntimeID string
|
||||
AgentPrivateKey string
|
||||
AgentTaskID string
|
||||
AgentFedRAMP bool
|
||||
IsAgentIdentity bool
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
TokenExpiresAt *time.Time
|
||||
IdentityKeys []string
|
||||
WarningTexts []string
|
||||
}
|
||||
|
||||
type codexJWTClaims struct {
|
||||
@@ -115,6 +120,10 @@ func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if req.Concurrency != nil && *req.Concurrency < 0 {
|
||||
response.BadRequest(c, "concurrency must be >= 0")
|
||||
return
|
||||
@@ -492,6 +501,41 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
|
||||
case string:
|
||||
item.AccessToken = strings.TrimSpace(raw)
|
||||
case map[string]any:
|
||||
if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) {
|
||||
if !ok {
|
||||
agentIdentity = raw
|
||||
}
|
||||
item.IsAgentIdentity = true
|
||||
item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"})
|
||||
item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"})
|
||||
item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"})
|
||||
item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"})
|
||||
item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"})
|
||||
item.Email = firstCodexString(agentIdentity, []string{"email"})
|
||||
item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"})
|
||||
item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"})
|
||||
if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" {
|
||||
return nil, errors.New("agent identity 缺少必要字段")
|
||||
}
|
||||
if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil {
|
||||
return nil, errors.New("agent identity private key 格式无效")
|
||||
}
|
||||
item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity
|
||||
item.Credentials["agent_runtime_id"] = item.AgentRuntimeID
|
||||
item.Credentials["agent_private_key"] = item.AgentPrivateKey
|
||||
item.Credentials["chatgpt_account_id"] = item.AccountID
|
||||
item.Credentials["chatgpt_user_id"] = item.UserID
|
||||
item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID)
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email)
|
||||
setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType)
|
||||
if item.AgentTaskID == "" {
|
||||
item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task")
|
||||
}
|
||||
item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID, item.UserID, item.Email, item.AgentRuntimeID)
|
||||
item.Name = buildCodexImportAccountName(item, entry.Index)
|
||||
return item, nil
|
||||
}
|
||||
item.AccessToken = firstCodexString(raw,
|
||||
[]string{"tokens", "access_token"},
|
||||
[]string{"tokens", "accessToken"},
|
||||
@@ -573,6 +617,9 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err
|
||||
return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index)
|
||||
}
|
||||
|
||||
if item.IsAgentIdentity {
|
||||
return item, nil
|
||||
}
|
||||
if item.AccessToken == "" {
|
||||
return nil, errors.New("缺少 accessToken/access_token")
|
||||
}
|
||||
@@ -727,6 +774,12 @@ func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAc
|
||||
if item == nil {
|
||||
return nil, nil, nil, nil, errors.New("导入项为空")
|
||||
}
|
||||
// Agent Identity has no OAuth access-token lifetime. Its runtime/task
|
||||
// lifecycle is handled by the upstream task recovery path, so it must not
|
||||
// be rejected or auto-paused by the OAuth import expiry policy.
|
||||
if item.IsAgentIdentity {
|
||||
return nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
var requestExpiresAt *time.Time
|
||||
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||||
@@ -808,6 +861,9 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||||
"openai_auth_mode": {},
|
||||
"token_type": {},
|
||||
"chatgpt_account_is_fedramp": {},
|
||||
"agent_runtime_id": {},
|
||||
"agent_private_key": {},
|
||||
"task_id": {},
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
@@ -838,6 +894,14 @@ func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refresh
|
||||
return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken)
|
||||
}
|
||||
|
||||
func buildCodexAgentIdentityKeys(accountID, userID, email, runtimeID string) []string {
|
||||
keys := buildCodexStoredIdentityKeys(accountID, userID, email, "")
|
||||
if runtimeID = strings.TrimSpace(runtimeID); runtimeID != "" {
|
||||
keys = append([]string{"agent:" + runtimeID}, keys...)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
|
||||
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
|
||||
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
|
||||
@@ -887,6 +951,10 @@ func (i *codexAccountIndex) Add(account service.Account) {
|
||||
for _, key := range keys {
|
||||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||||
}
|
||||
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
|
||||
key := "agent:" + runtimeID
|
||||
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *codexAccountIndex) remove(accountID int64) {
|
||||
@@ -1039,6 +1107,38 @@ func firstCodexString(obj map[string]any, paths ...[]string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) {
|
||||
for _, path := range paths {
|
||||
value, ok := codexPathValue(obj, path)
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if mapped, ok := value.(map[string]any); ok {
|
||||
return mapped, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func firstCodexBool(obj map[string]any, paths ...[]string) bool {
|
||||
for _, path := range paths {
|
||||
value, ok := codexPathValue(obj, path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch value := value.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) {
|
||||
value := firstCodexString(obj, path)
|
||||
if value != "" {
|
||||
|
||||
@@ -559,7 +559,7 @@ func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T)
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")},
|
||||
@@ -583,7 +583,7 @@ func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwo
|
||||
|
||||
func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) {
|
||||
svc := newCodexImportMemoryAdminService(nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -630,8 +630,9 @@ func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T)
|
||||
"chatgpt_user_id": "user-1",
|
||||
"access_token": existingToken,
|
||||
},
|
||||
Extra: map[string]any{"openai_long_context_billing_enabled": false},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
@@ -650,6 +651,9 @@ func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T)
|
||||
if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
|
||||
t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts)
|
||||
}
|
||||
if got := svc.updatedAccounts[0].input.Extra["openai_long_context_billing_enabled"]; got != false {
|
||||
t.Fatalf("openai_long_context_billing_enabled = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) {
|
||||
@@ -666,7 +670,7 @@ func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *te
|
||||
"access_token": oldToken,
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -705,7 +709,7 @@ func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *test
|
||||
"client_id": "client-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{"access_token": existingToken}},
|
||||
@@ -748,7 +752,7 @@ func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *te
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: map[string]any{
|
||||
@@ -794,7 +798,7 @@ func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) {
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
|
||||
entries := []codexImportEntry{
|
||||
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")},
|
||||
|
||||
@@ -460,6 +460,7 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
if created.Platform == service.PlatformAntigravity && created.Type == service.AccountTypeOAuth {
|
||||
privacyAccounts = append(privacyAccounts, created)
|
||||
}
|
||||
h.scheduleGrokImportProbe(created)
|
||||
result.AccountCreated++
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ func setupAccountDataRouter() (*gin.Engine, *stubAdminService) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router.GET("/api/v1/admin/accounts/data", h.ExportData)
|
||||
|
||||
@@ -52,6 +52,7 @@ type AccountHandler struct {
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
geminiOAuthService *service.GeminiOAuthService
|
||||
antigravityOAuthService *service.AntigravityOAuthService
|
||||
grokOAuthService service.GrokOAuthTokenService
|
||||
rateLimitService *service.RateLimitService
|
||||
accountUsageService *service.AccountUsageService
|
||||
accountTestService *service.AccountTestService
|
||||
@@ -60,6 +61,13 @@ type AccountHandler struct {
|
||||
sessionLimitCache service.SessionLimitCache
|
||||
rpmCache service.RPMCache
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator
|
||||
grokImportProber grokUsageProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamBillingProbeService) {
|
||||
h.upstreamBillingProbe = probe
|
||||
}
|
||||
|
||||
// NewAccountHandler creates a new admin account handler
|
||||
@@ -69,6 +77,7 @@ func NewAccountHandler(
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
antigravityOAuthService *service.AntigravityOAuthService,
|
||||
grokOAuthService service.GrokOAuthTokenService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
@@ -84,6 +93,7 @@ func NewAccountHandler(
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
geminiOAuthService: geminiOAuthService,
|
||||
antigravityOAuthService: antigravityOAuthService,
|
||||
grokOAuthService: grokOAuthService,
|
||||
rateLimitService: rateLimitService,
|
||||
accountUsageService: accountUsageService,
|
||||
accountTestService: accountTestService,
|
||||
@@ -784,6 +794,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := service.ValidateOpenAILongContextBillingExtra(req.Platform, req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
||||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||||
return
|
||||
@@ -851,6 +865,54 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
// OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。
|
||||
// 探测失败不影响账号创建响应。
|
||||
h.scheduleOpenAIResponsesProbe(createdAccount)
|
||||
h.scheduleGrokImportProbe(createdAccount)
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Duplicate handles creating an independent account from an existing account's configuration.
|
||||
// POST /api/v1/admin/accounts/:id/duplicate
|
||||
func (h *AccountHandler) Duplicate(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.accounts.duplicate",
|
||||
struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
}{AccountID: accountID},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
account, execErr := h.adminService.DuplicateAccount(ctx, accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.adminService.RecoverDuplicateAccount(c.Request.Context(), accountID, actorScope, c.GetHeader("Idempotency-Key"))
|
||||
if recoverErr != nil {
|
||||
slog.Warn("account_duplicate_recovery_failed", "account_id", accountID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
@@ -1173,6 +1235,19 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
return nil, "", fmt.Errorf("failed to clear account error: %w", clearErr)
|
||||
}
|
||||
}
|
||||
} else if account.Platform == service.PlatformGrok {
|
||||
if h.grokOAuthService == nil {
|
||||
return nil, "", fmt.Errorf("grok oauth service is not configured")
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to refresh Grok credentials: %w", err)
|
||||
}
|
||||
|
||||
newCredentials = service.MergeCredentials(account.Credentials, h.grokOAuthService.BuildAccountCredentials(tokenInfo))
|
||||
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
|
||||
newCredentials["base_url"] = baseURL
|
||||
}
|
||||
} else {
|
||||
// Use Anthropic/Claude OAuth service to refresh token
|
||||
tokenInfo, err := h.oauthService.RefreshAccountToken(ctx, account)
|
||||
@@ -1299,6 +1374,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
response.ErrorFrom(c, infraerrors.BadRequest("NOT_OAUTH", "cannot apply oauth credentials to non-OAuth account"))
|
||||
return
|
||||
}
|
||||
if err := service.ValidateOpenAILongContextBillingExtra(existing.Platform, req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
|
||||
Type: req.Type,
|
||||
@@ -1592,6 +1671,12 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
for _, item := range req.Accounts {
|
||||
if err := service.ValidateOpenAILongContextBillingExtra(item.Platform, item.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||||
success := 0
|
||||
@@ -1653,6 +1738,7 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
}
|
||||
// OpenAI APIKey 账号异步探测 /v1/responses 能力。
|
||||
h.scheduleOpenAIResponsesProbe(account)
|
||||
h.scheduleGrokImportProbe(account)
|
||||
success++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
|
||||
@@ -32,7 +32,7 @@ func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*
|
||||
func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
|
||||
return router
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
&config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
|
||||
nil,
|
||||
)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type duplicateAccountAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
calls int
|
||||
recoverCalls int
|
||||
accountID int64
|
||||
actorScope string
|
||||
operationKey string
|
||||
recoverScope string
|
||||
recoverKey string
|
||||
recoverErr error
|
||||
created bool
|
||||
}
|
||||
|
||||
type blockingDuplicateAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
calls atomic.Int32
|
||||
recoverCalls atomic.Int32
|
||||
recoverErr error
|
||||
}
|
||||
|
||||
type failOnceMarkSucceededRepo struct {
|
||||
*memoryIdempotencyRepoStub
|
||||
failNext bool
|
||||
}
|
||||
|
||||
func (r *failOnceMarkSucceededRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
|
||||
if r.failNext {
|
||||
r.failNext = false
|
||||
return errors.New("mark succeeded failed")
|
||||
}
|
||||
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.calls++
|
||||
s.accountID = accountID
|
||||
s.actorScope = actorScope
|
||||
s.operationKey = operationKey
|
||||
s.created = true
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *duplicateAccountAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
s.recoverCalls++
|
||||
s.recoverScope = actorScope
|
||||
s.recoverKey = operationKey
|
||||
if s.recoverErr != nil {
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
if !s.created {
|
||||
return nil, nil
|
||||
}
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) DuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.calls.Add(1)
|
||||
close(s.started)
|
||||
<-s.release
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *blockingDuplicateAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
|
||||
s.recoverCalls.Add(1)
|
||||
return nil, s.recoverErr
|
||||
}
|
||||
|
||||
func setupDuplicateAccountRouter(t *testing.T, svc service.AdminService) *gin.Engine {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(nil)
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/duplicate", handler.Duplicate)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRedactsCredentials(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
Credentials: map[string]any{"api_key": "top-secret-key"},
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.NotContains(t, recorder.Body.String(), "top-secret-key")
|
||||
var responseBody struct {
|
||||
Data struct {
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Schedulable bool `json:"schedulable"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Empty(t, responseBody.Data.Credentials)
|
||||
require.False(t, responseBody.Data.Schedulable)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRejectsInvalidID(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/duplicate", nil)
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, svc.calls)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := newMemoryIdempotencyRepoStub()
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, int64(42), svc.accountID)
|
||||
require.Equal(t, "admin:77", svc.actorScope)
|
||||
require.Equal(t, "duplicate-account-42", svc.operationKey)
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Equal(t, 2, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
require.Equal(t, "duplicate-account-42-recovery", svc.recoverKey)
|
||||
require.Contains(t, second.Body.String(), `"id":43`)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerPreservesIdempotencyErrorWhenRecoveryLookupFails(t *testing.T) {
|
||||
svc := &duplicateAccountAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery-error")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "IDEMPOTENCY_STORE_UNAVAILABLE")
|
||||
require.Equal(t, 1, svc.calls)
|
||||
require.Equal(t, 1, svc.recoverCalls)
|
||||
require.Equal(t, "admin:77", svc.recoverScope)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountHandlerDoesNotReexecuteWhileOriginalIsProcessing(t *testing.T) {
|
||||
svc := &blockingDuplicateAdminServiceStub{
|
||||
account: &service.Account{
|
||||
ID: 43,
|
||||
Name: "primary (Copy)",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
},
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
recoverErr: errors.New("recovery database unavailable"),
|
||||
}
|
||||
router := setupDuplicateAccountRouter(t, svc)
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-account-42-active")
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
firstDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { firstDone <- call() }()
|
||||
<-svc.started
|
||||
|
||||
second := call()
|
||||
require.Equal(t, http.StatusConflict, second.Code)
|
||||
require.Contains(t, second.Body.String(), "IDEMPOTENCY_IN_PROGRESS")
|
||||
require.Equal(t, int32(1), svc.calls.Load())
|
||||
require.Equal(t, int32(1), svc.recoverCalls.Load())
|
||||
|
||||
close(svc.release)
|
||||
select {
|
||||
case first := <-firstDone:
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("original duplicate request did not finish")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokRefreshOAuthStub struct {
|
||||
account *service.Account
|
||||
info *service.GrokTokenInfo
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *grokRefreshOAuthStub) RefreshAccountToken(_ context.Context, account *service.Account) (*service.GrokTokenInfo, error) {
|
||||
s.calls++
|
||||
s.account = account
|
||||
return s.info, nil
|
||||
}
|
||||
|
||||
func (s *grokRefreshOAuthStub) BuildAccountCredentials(info *service.GrokTokenInfo) map[string]any {
|
||||
return map[string]any{
|
||||
"access_token": info.AccessToken,
|
||||
"refresh_token": info.RefreshToken,
|
||||
"expires_at": info.ExpiresAt,
|
||||
"base_url": "https://api.x.ai/v1",
|
||||
}
|
||||
}
|
||||
|
||||
type grokRefreshAdminService struct {
|
||||
*stubAdminService
|
||||
updatedCredentials map[string]any
|
||||
}
|
||||
|
||||
func (s *grokRefreshAdminService) UpdateAccount(_ context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updatedCredentials = input.Credentials
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: input.Credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestRefreshSingleAccountRoutesGrokThroughGrokOAuthService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminSvc := &grokRefreshAdminService{stubAdminService: newStubAdminService()}
|
||||
grokOAuth := &grokRefreshOAuthStub{info: &service.GrokTokenInfo{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: 1_800_000_000,
|
||||
}}
|
||||
handler := NewAccountHandler(
|
||||
adminSvc,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
grokOAuth,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
account := &service.Account{
|
||||
ID: 4227,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"subscription_tier": "SUPER_GROK",
|
||||
"entitlement_status": "ACTIVE",
|
||||
},
|
||||
}
|
||||
|
||||
updated, warning, err := handler.refreshSingleAccount(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, warning)
|
||||
require.Equal(t, 1, grokOAuth.calls)
|
||||
require.Same(t, account, grokOAuth.account)
|
||||
require.Equal(t, "new-access", adminSvc.updatedCredentials["access_token"])
|
||||
require.Equal(t, "new-refresh", adminSvc.updatedCredentials["refresh_token"])
|
||||
require.Equal(t, "https://example.invalid/v1", adminSvc.updatedCredentials["base_url"])
|
||||
require.Equal(t, "SUPER_GROK", adminSvc.updatedCredentials["subscription_tier"])
|
||||
require.Equal(t, "ACTIVE", adminSvc.updatedCredentials["entitlement_status"])
|
||||
require.Equal(t, adminSvc.updatedCredentials, updated.Credentials)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.GET("/api/v1/admin/accounts", handler.List)
|
||||
return router, adminSvc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountAdminBoundariesRejectMalformedOpenAILongContextBillingValue(t *testing.T) {
|
||||
const malformedExtra = `"extra":{"openai_long_context_billing_enabled":"true"}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
mount func(*gin.Engine, *AccountHandler)
|
||||
setup func(*stubAdminService)
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
method: http.MethodPost,
|
||||
path: "/accounts",
|
||||
body: `{"name":"account","platform":"openai","type":"apikey","credentials":{"api_key":"test"},` + malformedExtra + `}`,
|
||||
mount: func(router *gin.Engine, handler *AccountHandler) { router.POST("/accounts", handler.Create) },
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
method: http.MethodPut,
|
||||
path: "/accounts/1",
|
||||
body: `{` + malformedExtra + `}`,
|
||||
mount: func(router *gin.Engine, handler *AccountHandler) { router.PUT("/accounts/:id", handler.Update) },
|
||||
setup: func(stub *stubAdminService) {
|
||||
stub.updateAccountErr = infraerrors.BadRequest("OPENAI_LONG_CONTEXT_BILLING_INVALID", "invalid")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bulk update",
|
||||
method: http.MethodPost,
|
||||
path: "/accounts/bulk-update",
|
||||
body: `{"account_ids":[1],` + malformedExtra + `}`,
|
||||
mount: func(router *gin.Engine, handler *AccountHandler) {
|
||||
router.POST("/accounts/bulk-update", handler.BulkUpdate)
|
||||
},
|
||||
setup: func(stub *stubAdminService) {
|
||||
stub.bulkUpdateAccountErr = infraerrors.BadRequest("OPENAI_LONG_CONTEXT_BILLING_INVALID", "invalid")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "batch create",
|
||||
method: http.MethodPost,
|
||||
path: "/accounts/batch",
|
||||
body: `{"accounts":[{"name":"account","platform":"openai","type":"apikey","credentials":{"api_key":"test"},` + malformedExtra + `}]}`,
|
||||
mount: func(router *gin.Engine, handler *AccountHandler) { router.POST("/accounts/batch", handler.BatchCreate) },
|
||||
},
|
||||
{
|
||||
name: "Codex session import",
|
||||
method: http.MethodPost,
|
||||
path: "/accounts/import-codex-session",
|
||||
body: `{"content":"token",` + malformedExtra + `}`,
|
||||
mount: func(router *gin.Engine, handler *AccountHandler) {
|
||||
router.POST("/accounts/import-codex-session", handler.ImportCodexSession)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := newStubAdminService()
|
||||
if tt.setup != nil {
|
||||
tt.setup(stub)
|
||||
}
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
tt.mount(router, handler)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
var responseBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Equal(t, "OPENAI_LONG_CONTEXT_BILLING_INVALID", responseBody.Reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountCreateBoundaryDoesNotApplyOpenAIValidationToOtherPlatforms(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(newStubAdminService(), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.POST("/accounts", handler.Create)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(
|
||||
`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"api_key":"test"},"extra":{"openai_long_context_billing_enabled":"provider-owned"}}`,
|
||||
))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMutation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := newStubAdminService()
|
||||
stub.getAccountResult = &service.Account{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}
|
||||
handler := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.POST("/accounts/:id/apply-oauth-credentials", handler.ApplyOAuthCredentials)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/accounts/1/apply-oauth-credentials", bytes.NewBufferString(
|
||||
`{"type":"oauth","credentials":{"access_token":"new-token"},"extra":{"openai_long_context_billing_enabled":"true"}}`,
|
||||
))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
var responseBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Equal(t, "OPENAI_LONG_CONTEXT_BILLING_INVALID", responseBody.Reason)
|
||||
require.Zero(t, stub.updateAccountCalls)
|
||||
require.Zero(t, stub.updateAccountExtraCalls)
|
||||
}
|
||||
|
||||
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil)
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/openai/create-from-codex-pat", bytes.NewBufferString(
|
||||
`{"access_token":"token","extra":{"openai_long_context_billing_enabled":1}}`,
|
||||
))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
var responseBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody))
|
||||
require.Equal(t, "OPENAI_LONG_CONTEXT_BILLING_INVALID", responseBody.Reason)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func setupAccountMixedChannelRouter(adminSvc *stubAdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
accountHandler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/check-mixed-channel", accountHandler.CheckMixedChannel)
|
||||
router.POST("/api/v1/admin/accounts", accountHandler.Create)
|
||||
router.PUT("/api/v1/admin/accounts/:id", accountHandler.Update)
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestAccountHandler_Create_AnthropicAPIKeyPassthroughExtraForwarded(t *testi
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upstreamBillingProbeEnabledRequest struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
|
||||
type upstreamBillingProbeBatchRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AccountHandler) GetUpstreamBillingProbeSettings(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) UpdateUpstreamBillingProbeSettings(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
var req service.UpstreamBillingProbeSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.upstreamBillingProbe.UpdateSettings(c.Request.Context(), &req); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
settings, err := h.upstreamBillingProbe.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, settings)
|
||||
}
|
||||
|
||||
func (h *AccountHandler) SetUpstreamBillingProbeEnabled(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
var req upstreamBillingProbeEnabledRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.upstreamBillingProbe.SetAccountEnabled(c.Request.Context(), accountID, *req.Enabled); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"account_id": accountID, "enabled": *req.Enabled})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) ProbeUpstreamBilling(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
snapshot, err := h.upstreamBillingProbe.ProbeAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.UpstreamBillingProbeResult{AccountID: accountID, Snapshot: snapshot})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) ProbeUpstreamBillingBatch(c *gin.Context) {
|
||||
if h.upstreamBillingProbe == nil {
|
||||
response.ErrorFrom(c, service.ErrUpstreamBillingProbeUnavailable)
|
||||
return
|
||||
}
|
||||
var req upstreamBillingProbeBatchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.AccountIDs) == 0 || len(req.AccountIDs) > service.UpstreamBillingProbeMaxBatchSize {
|
||||
response.BadRequest(c, "account_ids must contain between 1 and 20 items")
|
||||
return
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(req.AccountIDs))
|
||||
accountIDs := make([]int64, 0, len(req.AccountIDs))
|
||||
for _, accountID := range req.AccountIDs {
|
||||
if accountID <= 0 {
|
||||
response.BadRequest(c, "account_ids must contain positive IDs")
|
||||
return
|
||||
}
|
||||
if _, exists := seen[accountID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[accountID] = struct{}{}
|
||||
accountIDs = append(accountIDs, accountID)
|
||||
}
|
||||
response.Success(c, gin.H{"results": h.upstreamBillingProbe.ProbeAccounts(c.Request.Context(), accountIDs)})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupUpstreamBillingProbeRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.SetUpstreamBillingProbeService(service.NewUpstreamBillingProbeService(nil, nil, nil))
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/admin/accounts/upstream-billing-probe/settings", handler.GetUpstreamBillingProbeSettings)
|
||||
router.POST("/admin/accounts/upstream-billing-probe/batch", handler.ProbeUpstreamBillingBatch)
|
||||
router.PUT("/admin/accounts/:id/upstream-billing-probe", handler.SetUpstreamBillingProbeEnabled)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestAccountHandlerGetUpstreamBillingProbeSettingsReturnsDefaults(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/admin/accounts/upstream-billing-probe/settings", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response struct {
|
||||
Data service.UpstreamBillingProbeSettings `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.True(t, response.Data.Enabled)
|
||||
require.Equal(t, 30, response.Data.IntervalMinutes)
|
||||
}
|
||||
|
||||
func TestAccountHandlerProbeUpstreamBillingBatchValidatesIDs(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
|
||||
for _, body := range []string{`{"account_ids":[]}`, `{"account_ids":[0]}`} {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/accounts/upstream-billing-probe/batch", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSetUpstreamBillingProbeEnabledRejectsInvalidID(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/not-an-id/upstream-billing-probe", bytes.NewBufferString(`{"enabled":true}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSetUpstreamBillingProbeEnabledRequiresValue(t *testing.T) {
|
||||
router := setupUpstreamBillingProbeRouter()
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/admin/accounts/1/upstream-billing-probe", bytes.NewBufferString(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
groupHandler := NewGroupHandler(adminSvc, nil, nil)
|
||||
proxyHandler := NewProxyHandler(adminSvc)
|
||||
redeemHandler := NewRedeemHandler(adminSvc, nil)
|
||||
|
||||
@@ -33,6 +33,9 @@ type stubAdminService struct {
|
||||
createSparkShadowErr error
|
||||
updateAccountErr error
|
||||
bulkUpdateAccountErr error
|
||||
getAccountResult *service.Account
|
||||
updateAccountCalls int
|
||||
updateAccountExtraCalls int
|
||||
checkMixedErr error
|
||||
lastMixedCheck struct {
|
||||
accountID int64
|
||||
@@ -388,6 +391,9 @@ func (s *stubAdminService) ListOpenAISchedulableAccountsForSchedulerScore(_ cont
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
|
||||
if s.getAccountResult != nil {
|
||||
return s.getAccountResult, nil
|
||||
}
|
||||
account := service.Account{ID: id, Name: "account", Status: service.StatusActive}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -412,7 +418,17 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updateAccountCalls++
|
||||
if s.updateAccountErr != nil {
|
||||
return nil, s.updateAccountErr
|
||||
}
|
||||
@@ -421,6 +437,7 @@ func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *s
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||
s.updateAccountExtraCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuditLogHandler 操作审计日志管理接口。
|
||||
// 审计日志仅管理员可见;不提供单条删除,仅支持带 TOTP 验证的全量清空。
|
||||
type AuditLogHandler struct {
|
||||
auditService *service.AuditLogService
|
||||
totpService *service.TotpService
|
||||
}
|
||||
|
||||
// NewAuditLogHandler 创建审计日志处理器。
|
||||
func NewAuditLogHandler(auditService *service.AuditLogService, totpService *service.TotpService) *AuditLogHandler {
|
||||
return &AuditLogHandler{
|
||||
auditService: auditService,
|
||||
totpService: totpService,
|
||||
}
|
||||
}
|
||||
|
||||
// List 分页查询审计日志。
|
||||
// GET /api/v1/admin/audit-logs
|
||||
func (h *AuditLogHandler) List(c *gin.Context) {
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
filter := &service.AuditLogFilter{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
ActorEmail: strings.TrimSpace(c.Query("actor_email")),
|
||||
AuthMethod: strings.TrimSpace(c.Query("auth_method")),
|
||||
Action: strings.TrimSpace(c.Query("action")),
|
||||
Method: strings.TrimSpace(c.Query("method")),
|
||||
ClientIP: strings.TrimSpace(c.Query("client_ip")),
|
||||
Query: strings.TrimSpace(c.Query("q")),
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(c.Query("actor_user_id")); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid actor_user_id")
|
||||
return
|
||||
}
|
||||
filter.ActorUserID = &id
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("start_time")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid start_time, expect RFC3339")
|
||||
return
|
||||
}
|
||||
filter.StartTime = &t
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("end_time")); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid end_time, expect RFC3339")
|
||||
return
|
||||
}
|
||||
filter.EndTime = &t
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("success")); v != "" {
|
||||
success := v == "true"
|
||||
filter.Success = &success
|
||||
}
|
||||
|
||||
result, err := h.auditService.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Paginated(c, result.Logs, int64(result.Total), result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// Get 查询单条审计日志详情(含脱敏后的请求体)。
|
||||
// GET /api/v1/admin/audit-logs/:id
|
||||
func (h *AuditLogHandler) Get(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
response.BadRequest(c, "Invalid audit log id")
|
||||
return
|
||||
}
|
||||
item, err := h.auditService.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
type auditLogClearRequest struct {
|
||||
TotpCode string `json:"totp_code" binding:"required"`
|
||||
}
|
||||
|
||||
// Clear 全量清空审计日志。
|
||||
// POST /api/v1/admin/audit-logs/clear
|
||||
//
|
||||
// 安全要求(与需求对齐):
|
||||
// 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口)
|
||||
// 2. 未启用 TOTP 的管理员不允许清空
|
||||
// 3. admin API key(机器凭证)不允许清空
|
||||
// 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数)
|
||||
func (h *AuditLogHandler) Clear(c *gin.Context) {
|
||||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||||
"Admin API key cannot clear audit logs; a two-factor verified admin session is required",
|
||||
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
|
||||
return
|
||||
}
|
||||
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.Unauthorized(c, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req auditLogClearRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWithDetails(c, http.StatusBadRequest,
|
||||
"TOTP code is required to clear audit logs", "TOTP_CODE_REQUIRED", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 TOTP:未启用(ErrTotpNotSetup)、码错误、尝试过多均在此拦截。
|
||||
if err := h.totpService.VerifyCode(c.Request.Context(), subject.UserID, strings.TrimSpace(req.TotpCode)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
uid := subject.UserID
|
||||
role, _ := middleware.GetUserRoleFromContext(c)
|
||||
trace := &service.AuditLog{
|
||||
ActorUserID: &uid,
|
||||
ActorEmail: c.GetString(middleware.ContextKeyAuthEmail),
|
||||
ActorRole: role,
|
||||
AuthMethod: c.GetString("auth_method"),
|
||||
CredentialMasked: middleware.MaskedRequestCredential(c),
|
||||
Method: http.MethodPost,
|
||||
Path: c.FullPath(),
|
||||
ClientIP: ip.GetTrustedClientIP(c),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
StatusCode: http.StatusOK,
|
||||
}
|
||||
if requestID := c.Writer.Header().Get("X-Request-ID"); requestID != "" {
|
||||
trace.RequestID = requestID
|
||||
}
|
||||
|
||||
deleted, err := h.auditService.ClearAll(c.Request.Context(), trace)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 留痕记录已同步落库,跳过异步审计中间件的重复记录。
|
||||
middleware.SkipAudit(c)
|
||||
response.Success(c, gin.H{"deleted": deleted})
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (f *failingAdminService) UpdateAccount(ctx context.Context, id int64, input
|
||||
func setupAccountHandlerWithService(adminSvc service.AdminService) (*gin.Engine, *AccountHandler) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/batch-update-credentials", handler.BatchUpdateCredentials)
|
||||
return router, handler
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type channelModelPricingRequest struct {
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
@@ -115,6 +116,7 @@ type channelModelPricingResponse struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
@@ -222,6 +224,7 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -273,6 +276,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
@@ -498,6 +502,7 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type duplicateChannelMonitorHandlerRepoStub struct {
|
||||
service.ChannelMonitorRepository
|
||||
source *service.ChannelMonitor
|
||||
byOperation map[string]*service.ChannelMonitor
|
||||
createCalls int
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) GetByID(_ context.Context, id int64) (*service.ChannelMonitor, error) {
|
||||
if r.source == nil || r.source.ID != id {
|
||||
return nil, service.ErrChannelMonitorNotFound
|
||||
}
|
||||
return r.source, nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) Create(_ context.Context, monitor *service.ChannelMonitor) error {
|
||||
r.createCalls++
|
||||
monitor.ID = int64(100 + r.createCalls)
|
||||
stored := *monitor
|
||||
if stored.DuplicateOperationID != "" {
|
||||
if r.byOperation == nil {
|
||||
r.byOperation = make(map[string]*service.ChannelMonitor)
|
||||
}
|
||||
r.byOperation[stored.DuplicateOperationID] = &stored
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *duplicateChannelMonitorHandlerRepoStub) FindByDuplicateOperationID(_ context.Context, operationID string) (*service.ChannelMonitor, error) {
|
||||
monitor := r.byOperation[operationID]
|
||||
if monitor == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned := *monitor
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
type duplicateChannelMonitorHandlerEncryptor struct{}
|
||||
|
||||
func (duplicateChannelMonitorHandlerEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
return "ENC:" + plaintext, nil
|
||||
}
|
||||
|
||||
func (duplicateChannelMonitorHandlerEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
return strings.TrimPrefix(ciphertext, "ENC:"), nil
|
||||
}
|
||||
|
||||
func setupDuplicateChannelMonitorRouter(t *testing.T) (*gin.Engine, *duplicateChannelMonitorHandlerRepoStub) {
|
||||
t.Helper()
|
||||
previousCoordinator := service.DefaultIdempotencyCoordinator()
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
|
||||
newMemoryIdempotencyRepoStub(),
|
||||
service.DefaultIdempotencyConfig(),
|
||||
))
|
||||
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
|
||||
|
||||
repo := &duplicateChannelMonitorHandlerRepoStub{
|
||||
source: &service.ChannelMonitor{
|
||||
ID: 42,
|
||||
Name: "primary",
|
||||
Provider: service.MonitorProviderOpenAI,
|
||||
APIMode: service.MonitorAPIModeResponses,
|
||||
Endpoint: "https://api.example.com",
|
||||
APIKey: "ENC:top-secret",
|
||||
PrimaryModel: "gpt-5.4-mini",
|
||||
ExtraModels: []string{"gpt-5.4"},
|
||||
Enabled: true,
|
||||
IntervalSeconds: 60,
|
||||
BodyOverrideMode: service.MonitorBodyOverrideModeOff,
|
||||
},
|
||||
}
|
||||
monitorService := service.NewChannelMonitorService(repo, duplicateChannelMonitorHandlerEncryptor{})
|
||||
handler := NewChannelMonitorHandler(monitorService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/admin/channel-monitors/:id/duplicate", handler.Duplicate)
|
||||
return router, repo
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRedactsKeyAndReplaysRetry(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
|
||||
first := httptest.NewRecorder()
|
||||
router.ServeHTTP(first, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, 1, repo.createCalls)
|
||||
require.Contains(t, first.Body.String(), `"name":"primary (Copy)"`)
|
||||
require.Contains(t, first.Body.String(), `"api_key_masked":"top-***"`)
|
||||
require.Contains(t, first.Body.String(), `"created_by":77`)
|
||||
require.Contains(t, first.Body.String(), `"enabled":false`)
|
||||
require.NotContains(t, first.Body.String(), "top-secret")
|
||||
|
||||
retryRequest := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
retryRequest.Header.Set("Idempotency-Key", "duplicate-channel-monitor-42")
|
||||
retry := httptest.NewRecorder()
|
||||
router.ServeHTTP(retry, retryRequest)
|
||||
|
||||
require.Equal(t, http.StatusOK, retry.Code)
|
||||
require.Equal(t, "true", retry.Header().Get("X-Idempotency-Replayed"))
|
||||
require.Equal(t, 1, repo.createCalls)
|
||||
require.JSONEq(t, first.Body.String(), retry.Body.String())
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRejectsInvalidID(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/not-a-number/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-invalid")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, repo.createCalls)
|
||||
}
|
||||
|
||||
func TestDuplicateChannelMonitorHandlerRecoversAfterMarkSucceededFailure(t *testing.T) {
|
||||
router, repo := setupDuplicateChannelMonitorRouter(t)
|
||||
idempotencyRepo := &failOnceMarkSucceededRepo{
|
||||
memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(),
|
||||
failNext: true,
|
||||
}
|
||||
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(
|
||||
idempotencyRepo,
|
||||
service.DefaultIdempotencyConfig(),
|
||||
))
|
||||
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/channel-monitors/42/duplicate", nil)
|
||||
request.Header.Set("Idempotency-Key", "duplicate-channel-monitor-recovery")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
first := call()
|
||||
second := call()
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
require.Equal(t, "true", first.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
|
||||
require.Equal(t, 1, repo.createCalls, "ambiguous retries must not repeat the create side effect")
|
||||
require.Contains(t, second.Body.String(), `"id":101`)
|
||||
require.Contains(t, second.Body.String(), `"api_key_masked":"top-***"`)
|
||||
require.NotContains(t, second.Body.String(), "top-secret")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -37,11 +39,11 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"required,max=500"`
|
||||
APIKey string `json:"api_key" binding:"required,max=2000"`
|
||||
PrimaryModel string `json:"primary_model" binding:"required,max=200"`
|
||||
PrimaryModel string `json:"primary_model" binding:"max=200"`
|
||||
ExtraModels []string `json:"extra_models"`
|
||||
GroupName string `json:"group_name" binding:"max=100"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
@@ -55,7 +57,7 @@ type channelMonitorCreateRequest struct {
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
@@ -333,6 +335,63 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
|
||||
response.Created(c, channelMonitorToResponse(m))
|
||||
}
|
||||
|
||||
// Duplicate POST /api/v1/admin/channel-monitors/:id/duplicate
|
||||
func (h *ChannelMonitorHandler) Duplicate(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
subject, _ := middleware2.GetAuthSubjectFromContext(c)
|
||||
actorScope := adminActorScope(c)
|
||||
|
||||
result, err := executeAdminIdempotent(
|
||||
c,
|
||||
"admin.channel_monitors.duplicate",
|
||||
struct {
|
||||
MonitorID int64 `json:"monitor_id"`
|
||||
}{MonitorID: id},
|
||||
service.DefaultWriteIdempotencyTTL(),
|
||||
func(ctx context.Context) (any, error) {
|
||||
monitor, err := h.monitorService.Duplicate(
|
||||
ctx,
|
||||
id,
|
||||
subject.UserID,
|
||||
actorScope,
|
||||
c.GetHeader("Idempotency-Key"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return channelMonitorToResponse(monitor), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
reason := infraerrors.Reason(err)
|
||||
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
|
||||
recovered, recoverErr := h.monitorService.RecoverDuplicate(
|
||||
c.Request.Context(),
|
||||
id,
|
||||
actorScope,
|
||||
c.GetHeader("Idempotency-Key"),
|
||||
)
|
||||
if recoverErr != nil {
|
||||
slog.Warn("channel_monitor_duplicate_recovery_failed", "monitor_id", id, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
|
||||
} else if recovered != nil {
|
||||
c.Header("X-Idempotency-Recovered", "true")
|
||||
response.Success(c, channelMonitorToResponse(recovered))
|
||||
return
|
||||
}
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result != nil && result.Replayed {
|
||||
c.Header("X-Idempotency-Replayed", "true")
|
||||
}
|
||||
response.Success(c, result.Data)
|
||||
}
|
||||
|
||||
// Update PUT /api/v1/admin/channel-monitors/:id
|
||||
func (h *ChannelMonitorHandler) Update(c *gin.Context) {
|
||||
id, ok := ParseChannelMonitorID(c)
|
||||
|
||||
@@ -26,7 +26,7 @@ func NewChannelMonitorRequestTemplateHandler(templateService *service.ChannelMon
|
||||
|
||||
type channelMonitorTemplateCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
ExtraHeaders map[string]string `json:"extra_headers"`
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
grokImportProbeConcurrency = 3
|
||||
grokImportProbeTimeout = 25 * time.Second
|
||||
)
|
||||
|
||||
type grokUsageProber interface {
|
||||
ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error)
|
||||
}
|
||||
|
||||
type grokImportProbeTask struct {
|
||||
prober grokUsageProber
|
||||
accountID int64
|
||||
}
|
||||
|
||||
type grokImportProbeScheduler struct {
|
||||
mu sync.Mutex
|
||||
queue []grokImportProbeTask
|
||||
concurrency int
|
||||
workers int
|
||||
maxWorkers int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var defaultGrokImportProbeScheduler = newGrokImportProbeScheduler(
|
||||
grokImportProbeConcurrency,
|
||||
grokImportProbeTimeout,
|
||||
)
|
||||
|
||||
func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokImportProbeScheduler {
|
||||
if concurrency <= 0 {
|
||||
concurrency = 1
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = grokImportProbeTimeout
|
||||
}
|
||||
return &grokImportProbeScheduler{
|
||||
concurrency: concurrency,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) schedule(prober grokUsageProber, account *service.Account) {
|
||||
if s == nil || prober == nil || account == nil || account.ID <= 0 {
|
||||
return
|
||||
}
|
||||
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID})
|
||||
if s.workers < s.concurrency {
|
||||
s.workers++
|
||||
if s.workers > s.maxWorkers {
|
||||
s.maxWorkers = s.workers
|
||||
}
|
||||
go s.worker()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) worker() {
|
||||
for {
|
||||
task, ok := s.nextTask()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.run(task.prober, task.accountID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.queue) == 0 {
|
||||
s.workers--
|
||||
return grokImportProbeTask{}, false
|
||||
}
|
||||
task := s.queue[0]
|
||||
s.queue[0] = grokImportProbeTask{}
|
||||
s.queue = s.queue[1:]
|
||||
if len(s.queue) == 0 {
|
||||
s.queue = nil
|
||||
}
|
||||
return task, true
|
||||
}
|
||||
|
||||
func (s *grokImportProbeScheduler) run(prober grokUsageProber, accountID int64) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.Error(
|
||||
"grok_import_active_probe_panic",
|
||||
"account_id", accountID,
|
||||
"recovery_type", panicType(recovered),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Queue time is intentionally excluded: every imported account is probed,
|
||||
// while this timeout only bounds the actual upstream probe execution.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
|
||||
defer cancel()
|
||||
result, err := prober.ProbeUsage(ctx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn(
|
||||
"grok_import_active_probe_failed",
|
||||
"account_id", accountID,
|
||||
"status", infraerrors.Code(err),
|
||||
"reason", infraerrors.Reason(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
slog.Warn(
|
||||
"grok_import_active_probe_failed",
|
||||
"account_id", accountID,
|
||||
"reason", "empty_result",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info(
|
||||
"grok_import_active_probe_completed",
|
||||
"account_id", accountID,
|
||||
"model", result.Model,
|
||||
"status", result.StatusCode,
|
||||
"headers_observed", result.HeadersObserved,
|
||||
)
|
||||
}
|
||||
|
||||
func panicType(value any) string {
|
||||
switch value.(type) {
|
||||
case string:
|
||||
return "string"
|
||||
case error:
|
||||
return "error"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AccountHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
defaultGrokImportProbeScheduler.schedule(h.grokImportProber, account)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
defaultGrokImportProbeScheduler.schedule(h.importProber, account)
|
||||
}
|
||||
|
||||
// ProvideAccountHandler injects the Grok active prober for production while
|
||||
// keeping NewAccountHandler convenient for focused unit tests.
|
||||
func ProvideAccountHandler(
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
geminiOAuthService *service.GeminiOAuthService,
|
||||
antigravityOAuthService *service.AntigravityOAuthService,
|
||||
grokOAuthService service.GrokOAuthTokenService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
accountUsageService *service.AccountUsageService,
|
||||
accountTestService *service.AccountTestService,
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
crsSyncService *service.CRSSyncService,
|
||||
sessionLimitCache service.SessionLimitCache,
|
||||
rpmCache service.RPMCache,
|
||||
tokenCacheInvalidator service.TokenCacheInvalidator,
|
||||
grokQuotaService *service.GrokQuotaService,
|
||||
) *AccountHandler {
|
||||
handler := NewAccountHandler(
|
||||
adminService,
|
||||
oauthService,
|
||||
openaiOAuthService,
|
||||
geminiOAuthService,
|
||||
antigravityOAuthService,
|
||||
grokOAuthService,
|
||||
rateLimitService,
|
||||
accountUsageService,
|
||||
accountTestService,
|
||||
concurrencyService,
|
||||
crsSyncService,
|
||||
sessionLimitCache,
|
||||
rpmCache,
|
||||
tokenCacheInvalidator,
|
||||
)
|
||||
handler.grokImportProber = grokQuotaService
|
||||
return handler
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokImportAdminService struct {
|
||||
*stubAdminService
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func newGrokImportAdminService() *grokImportAdminService {
|
||||
return &grokImportAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
nextID: 500,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportAdminService) CreateAccount(_ context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
s.mu.Unlock()
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Name: input.Name,
|
||||
Platform: input.Platform,
|
||||
Type: input.Type,
|
||||
Credentials: input.Credentials,
|
||||
Extra: input.Extra,
|
||||
ProxyID: input.ProxyID,
|
||||
Concurrency: input.Concurrency,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type grokImportOAuthClientStub struct{}
|
||||
|
||||
func (grokImportOAuthClientStub) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
|
||||
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
|
||||
}
|
||||
|
||||
func TestGrokSSOBatchImportKeepsCreatedAccountsWhenOneAutomaticProbeFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
adminService := newGrokImportAdminService()
|
||||
oauthService := service.NewGrokOAuthService(nil, grokImportOAuthClientStub{})
|
||||
defer oauthService.Stop()
|
||||
prober := newGrokImportProbeStub(3)
|
||||
prober.failures[502] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
|
||||
handler := NewGrokOAuthHandler(oauthService, adminService, nil, nil)
|
||||
handler.importProber = prober
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/sso-to-oauth", handler.CreateAccountsFromSSO)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/grok/sso-to-oauth",
|
||||
strings.NewReader(`{"sso_tokens":["sso-one","sso-two","sso-three"]}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"created"`)
|
||||
require.NotContains(t, recorder.Body.String(), `GROK_TEST_PROBE_FAILED`)
|
||||
for i := 0; i < 3; i++ {
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
}
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Equal(t, map[int64]int{501: 1, 502: 1, 503: 1}, calls)
|
||||
}
|
||||
|
||||
func TestAccountCreateWithoutAutomaticGrokProbeServiceStillSucceeds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewAccountHandler(
|
||||
newGrokImportAdminService(),
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts", handler.Create)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts",
|
||||
strings.NewReader(`{"name":"grok-rt","platform":"grok","type":"oauth","credentials":{"refresh_token":"secret"}}`),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokImportProbeStub struct {
|
||||
mu sync.Mutex
|
||||
calls map[int64]int
|
||||
failures map[int64]error
|
||||
active int
|
||||
maxActive int
|
||||
deadlineSeen bool
|
||||
block <-chan struct{}
|
||||
started chan int64
|
||||
done chan int64
|
||||
}
|
||||
|
||||
func newGrokImportProbeStub(buffer int) *grokImportProbeStub {
|
||||
return &grokImportProbeStub{
|
||||
calls: make(map[int64]int),
|
||||
failures: make(map[int64]error),
|
||||
started: make(chan int64, buffer),
|
||||
done: make(chan int64, buffer),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *grokImportProbeStub) ProbeUsage(ctx context.Context, accountID int64) (*service.GrokQuotaProbeResult, error) {
|
||||
_, deadlineSeen := ctx.Deadline()
|
||||
s.mu.Lock()
|
||||
s.calls[accountID]++
|
||||
s.active++
|
||||
if s.active > s.maxActive {
|
||||
s.maxActive = s.active
|
||||
}
|
||||
s.deadlineSeen = s.deadlineSeen || deadlineSeen
|
||||
s.mu.Unlock()
|
||||
|
||||
s.started <- accountID
|
||||
var ctxErr error
|
||||
if s.block != nil {
|
||||
select {
|
||||
case <-s.block:
|
||||
case <-ctx.Done():
|
||||
ctxErr = ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
failure := s.failures[accountID]
|
||||
s.mu.Unlock()
|
||||
s.done <- accountID
|
||||
if ctxErr != nil {
|
||||
return nil, ctxErr
|
||||
}
|
||||
if failure != nil {
|
||||
return nil, failure
|
||||
}
|
||||
return &service.GrokQuotaProbeResult{
|
||||
Source: "active_probe",
|
||||
Model: "grok-4.5",
|
||||
StatusCode: 200,
|
||||
ResetSupported: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *grokImportProbeStub) snapshot() (map[int64]int, int, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
calls := make(map[int64]int, len(s.calls))
|
||||
for id, count := range s.calls {
|
||||
calls[id] = count
|
||||
}
|
||||
return calls, s.maxActive, s.deadlineSeen
|
||||
}
|
||||
|
||||
type grokImportProbeSchedulerTestSnapshot struct {
|
||||
queued int
|
||||
workers int
|
||||
maxWorkers int
|
||||
}
|
||||
|
||||
func snapshotGrokImportProbeScheduler(s *grokImportProbeScheduler) grokImportProbeSchedulerTestSnapshot {
|
||||
if s == nil {
|
||||
return grokImportProbeSchedulerTestSnapshot{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return grokImportProbeSchedulerTestSnapshot{
|
||||
queued: len(s.queue),
|
||||
workers: s.workers,
|
||||
maxWorkers: s.maxWorkers,
|
||||
}
|
||||
}
|
||||
|
||||
func newGrokOAuthImportAccount(id int64) *service.Account {
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}
|
||||
}
|
||||
|
||||
func awaitGrokProbeSignal(t *testing.T, signals <-chan int64) int64 {
|
||||
t.Helper()
|
||||
select {
|
||||
case id := <-signals:
|
||||
return id
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for Grok import probe")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(101))
|
||||
require.Equal(t, int64(101), awaitGrokProbeSignal(t, prober.done))
|
||||
|
||||
calls, maxActive, deadlineSeen := prober.snapshot()
|
||||
require.Equal(t, map[int64]int{101: 1}, calls)
|
||||
require.Equal(t, 1, maxActive)
|
||||
require.True(t, deadlineSeen)
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot := snapshotGrokImportProbeScheduler(scheduler)
|
||||
return snapshot.queued == 0 && snapshot.workers == 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
|
||||
const taskCount = 100
|
||||
release := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(3, time.Second)
|
||||
prober := newGrokImportProbeStub(taskCount)
|
||||
prober.block = release
|
||||
prober.failures[150] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "sensitive-upstream-body")
|
||||
|
||||
for id := int64(101); id < 101+taskCount; id++ {
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(id))
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
awaitGrokProbeSignal(t, prober.started)
|
||||
}
|
||||
snapshot := snapshotGrokImportProbeScheduler(scheduler)
|
||||
require.Equal(t, 97, snapshot.queued)
|
||||
require.Equal(t, 3, snapshot.workers)
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
select {
|
||||
case id := <-prober.started:
|
||||
t.Fatalf("probe %d started before a concurrency slot was released", id)
|
||||
case <-time.After(75 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
for i := 0; i < taskCount; i++ {
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
}
|
||||
|
||||
calls, maxActive, _ := prober.snapshot()
|
||||
require.Len(t, calls, taskCount)
|
||||
for id := int64(101); id < 101+taskCount; id++ {
|
||||
require.Equal(t, 1, calls[id])
|
||||
}
|
||||
require.Equal(t, 3, maxActive)
|
||||
require.Eventually(t, func() bool {
|
||||
snapshot = snapshotGrokImportProbeScheduler(scheduler)
|
||||
return snapshot.queued == 0 && snapshot.workers == 0
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Equal(t, 3, snapshot.maxWorkers)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
|
||||
neverRelease := make(chan struct{})
|
||||
scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
prober.block = neverRelease
|
||||
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(201))
|
||||
require.Equal(t, int64(201), awaitGrokProbeSignal(t, prober.done))
|
||||
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Equal(t, 1, calls[201])
|
||||
}
|
||||
|
||||
func TestGrokImportProbeSchedulerSkipsMissingServiceAndNonGrokAccounts(t *testing.T) {
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
|
||||
scheduler.schedule(nil, newGrokOAuthImportAccount(301))
|
||||
scheduler.schedule(prober, &service.Account{ID: 302, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth})
|
||||
scheduler.schedule(prober, &service.Account{ID: 303, Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey})
|
||||
|
||||
select {
|
||||
case id := <-prober.started:
|
||||
t.Fatalf("unexpected probe for account %d", id)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
calls, _, _ := prober.snapshot()
|
||||
require.Empty(t, calls)
|
||||
}
|
||||
|
||||
func TestGrokImportProbeFailureLogDoesNotIncludeErrorMessage(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
previousLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
defer slog.SetDefault(previousLogger)
|
||||
|
||||
scheduler := newGrokImportProbeScheduler(1, time.Second)
|
||||
prober := newGrokImportProbeStub(1)
|
||||
prober.failures[401] = infraerrors.New(502, "GROK_TEST_PROBE_FAILED", "refresh-token-secret")
|
||||
scheduler.schedule(prober, newGrokOAuthImportAccount(401))
|
||||
awaitGrokProbeSignal(t, prober.done)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return bytes.Contains(logs.Bytes(), []byte("grok_import_active_probe_failed"))
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
require.Contains(t, logs.String(), "GROK_TEST_PROBE_FAILED")
|
||||
require.NotContains(t, logs.String(), "refresh-token-secret")
|
||||
}
|
||||
@@ -1,31 +1,44 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const grokSSOImportConcurrency = 3
|
||||
|
||||
type GrokOAuthHandler struct {
|
||||
grokOAuthService *service.GrokOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.GrokQuotaService
|
||||
importProber grokUsageProber
|
||||
reconciler service.GrokOAuthReconciler
|
||||
}
|
||||
|
||||
func NewGrokOAuthHandler(
|
||||
grokOAuthService *service.GrokOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.GrokQuotaService,
|
||||
reconciler service.GrokOAuthReconciler,
|
||||
) *GrokOAuthHandler {
|
||||
return &GrokOAuthHandler{
|
||||
grokOAuthService: grokOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
importProber: quotaService,
|
||||
reconciler: reconciler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +164,50 @@ func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
response.Success(c, dto.AccountFromService(updatedAccount))
|
||||
}
|
||||
|
||||
type GrokOAuthReconcileRequest struct {
|
||||
DryRun *bool `json:"dry_run"`
|
||||
Apply bool `json:"apply"`
|
||||
AfterID int64 `json:"after_id"`
|
||||
Limit int `json:"limit"`
|
||||
RefreshWindowSeconds int64 `json:"refresh_window_seconds"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ReconcileOAuthAccounts(c *gin.Context) {
|
||||
var req GrokOAuthReconcileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request")
|
||||
return
|
||||
}
|
||||
dryRun := true
|
||||
if req.DryRun != nil {
|
||||
dryRun = *req.DryRun
|
||||
}
|
||||
if req.Apply == dryRun {
|
||||
response.ErrorFrom(c, service.ErrGrokOAuthReconcileMode)
|
||||
return
|
||||
}
|
||||
if req.RefreshWindowSeconds < 0 || req.RefreshWindowSeconds > int64((24*time.Hour)/time.Second) {
|
||||
response.ErrorFrom(c, service.ErrGrokOAuthReconcileWindow)
|
||||
return
|
||||
}
|
||||
if h.reconciler == nil {
|
||||
response.InternalError(c, "Grok OAuth reconciliation service is unavailable")
|
||||
return
|
||||
}
|
||||
result, err := h.reconciler.ReconcileGrokOAuth(c.Request.Context(), service.GrokOAuthReconcileInput{
|
||||
DryRun: dryRun,
|
||||
Apply: req.Apply,
|
||||
AfterID: req.AfterID,
|
||||
Limit: req.Limit,
|
||||
RefreshWindow: time.Duration(req.RefreshWindowSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
@@ -202,9 +259,243 @@ func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
h.scheduleGrokImportProbe(account)
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
type GrokSSOToOAuthRequest struct {
|
||||
SSOTokens []string `json:"sso_tokens"`
|
||||
SSOToken string `json:"sso_token"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
LoadFactor *int `json:"load_factor"`
|
||||
Priority int `json:"priority"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
}
|
||||
|
||||
type GrokSSOToOAuthItemResult struct {
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Account *dto.Account `json:"account,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type GrokSSOToOAuthResponse struct {
|
||||
Created []GrokSSOToOAuthItemResult `json:"created"`
|
||||
Failed []GrokSSOToOAuthItemResult `json:"failed"`
|
||||
}
|
||||
|
||||
type grokSSOImportJob struct {
|
||||
index int
|
||||
token string
|
||||
}
|
||||
|
||||
type grokSSOImportWorkerResult struct {
|
||||
created bool
|
||||
item GrokSSOToOAuthItemResult
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) CreateAccountsFromSSO(c *gin.Context) {
|
||||
var req GrokSSOToOAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokens := normalizeSSOImportTokens(req.SSOTokens, req.SSOToken)
|
||||
if len(tokens) == 0 {
|
||||
response.BadRequest(c, "sso_tokens is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
workerCount := grokSSOImportConcurrency
|
||||
if len(tokens) < workerCount {
|
||||
workerCount = len(tokens)
|
||||
}
|
||||
jobs := make(chan grokSSOImportJob)
|
||||
items := make([]grokSSOImportWorkerResult, len(tokens))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workerCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for job := range jobs {
|
||||
items[job.index] = h.safeCreateAccountFromSSOToken(ctx, req, job.token, job.index+1, len(tokens))
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i, token := range tokens {
|
||||
jobs <- grokSSOImportJob{index: i, token: token}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
result := GrokSSOToOAuthResponse{
|
||||
Created: make([]GrokSSOToOAuthItemResult, 0, len(tokens)),
|
||||
Failed: make([]GrokSSOToOAuthItemResult, 0),
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.created {
|
||||
result.Created = append(result.Created, item.item)
|
||||
} else {
|
||||
result.Failed = append(result.Failed, item.item)
|
||||
}
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) safeCreateAccountFromSSOToken(ctx context.Context, req GrokSSOToOAuthRequest, token string, index, total int) (result grokSSOImportWorkerResult) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.Error("grok_sso_import_worker_panic", "index", index, "recover", recovered)
|
||||
result = grokSSOImportWorkerResult{
|
||||
item: GrokSSOToOAuthItemResult{
|
||||
Index: index,
|
||||
Error: fmt.Sprintf("internal worker panic: %v", recovered),
|
||||
},
|
||||
}
|
||||
}
|
||||
}()
|
||||
return h.createAccountFromSSOToken(ctx, req, token, index, total)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req GrokSSOToOAuthRequest, token string, index, total int) grokSSOImportWorkerResult {
|
||||
tokenInfo, err := h.grokOAuthService.ConvertFromSSO(ctx, token, req.ProxyID)
|
||||
if err != nil {
|
||||
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Error: grokSSOImportErrorMessage(err)}}
|
||||
}
|
||||
|
||||
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
credentials = service.MergeCredentials(cloneGrokSSOMap(req.Credentials), credentials)
|
||||
name := grokSSOImportAccountName(req.Name, tokenInfo, index, total)
|
||||
expiresAt, autoPauseOnExpired := grokSSOImportExpiry(req.ExpiresAt, req.AutoPauseOnExpired, tokenInfo)
|
||||
account, err := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
|
||||
Name: name,
|
||||
Notes: req.Notes,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
Extra: cloneGrokSSOMap(req.Extra),
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
LoadFactor: req.LoadFactor,
|
||||
Priority: req.Priority,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
GroupIDs: append([]int64(nil), req.GroupIDs...),
|
||||
ExpiresAt: expiresAt,
|
||||
AutoPauseOnExpired: autoPauseOnExpired,
|
||||
})
|
||||
if err != nil {
|
||||
return grokSSOImportWorkerResult{item: GrokSSOToOAuthItemResult{Index: index, Name: name, Email: tokenInfo.Email, Error: grokSSOImportErrorMessage(err)}}
|
||||
}
|
||||
h.scheduleGrokImportProbe(account)
|
||||
return grokSSOImportWorkerResult{
|
||||
created: true,
|
||||
item: GrokSSOToOAuthItemResult{
|
||||
Index: index,
|
||||
Name: name,
|
||||
Email: tokenInfo.Email,
|
||||
Account: dto.AccountFromService(account),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
|
||||
if tokenInfo == nil || strings.TrimSpace(tokenInfo.RefreshToken) != "" || tokenInfo.ExpiresAt <= 0 {
|
||||
return requestExpiresAt, requestAutoPause
|
||||
}
|
||||
|
||||
expiresAt := tokenInfo.ExpiresAt
|
||||
if requestExpiresAt != nil && *requestExpiresAt > 0 && *requestExpiresAt < expiresAt {
|
||||
expiresAt = *requestExpiresAt
|
||||
}
|
||||
autoPause := true
|
||||
return &expiresAt, &autoPause
|
||||
}
|
||||
|
||||
func cloneGrokSSOMap(source map[string]any) map[string]any {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
clone[key] = cloneGrokSSOValue(value)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneGrokSSOValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneGrokSSOMap(v)
|
||||
case []any:
|
||||
clone := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
clone[i] = cloneGrokSSOValue(item)
|
||||
}
|
||||
return clone
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSSOImportTokens(tokens []string, single string) []string {
|
||||
items := make([]string, 0, len(tokens)+1)
|
||||
if strings.TrimSpace(single) != "" {
|
||||
items = append(items, single)
|
||||
}
|
||||
items = append(items, tokens...)
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts := strings.Split(strings.NewReplacer(",", "\n", "\r", "\n").Replace(item), "\n")
|
||||
for _, token := range parts {
|
||||
if token = xai.NormalizeSSOToken(token); token == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[token]; ok {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
result = append(result, token)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func grokSSOImportAccountName(base string, tokenInfo *service.GrokTokenInfo, index, total int) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" && tokenInfo != nil {
|
||||
base = strings.TrimSpace(tokenInfo.Email)
|
||||
}
|
||||
if base == "" {
|
||||
base = "Grok OAuth Account"
|
||||
}
|
||||
if total > 1 {
|
||||
return base + " #" + strconv.Itoa(index)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func grokSSOImportErrorMessage(err error) string {
|
||||
status := infraerrors.FromError(err)
|
||||
if status == nil {
|
||||
return ""
|
||||
}
|
||||
if status.Reason != "" {
|
||||
return status.Reason + ": " + status.Message
|
||||
}
|
||||
return status.Message
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -215,7 +506,7 @@ func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.ProbeUsage(c.Request.Context(), accountID)
|
||||
result, err := h.quotaService.QueryQuota(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -41,17 +42,48 @@ func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, u
|
||||
}
|
||||
|
||||
type grokQuotaHandlerUpstream struct {
|
||||
resp *http.Response
|
||||
lastReq *http.Request
|
||||
lastBody []byte
|
||||
mu sync.Mutex
|
||||
requests []*http.Request
|
||||
bodies [][]byte
|
||||
}
|
||||
|
||||
type grokOAuthReconcilerStub struct {
|
||||
input service.GrokOAuthReconcileInput
|
||||
calls int
|
||||
result *service.GrokOAuthReconcileResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *grokOAuthReconcilerStub) ReconcileGrokOAuth(_ context.Context, input service.GrokOAuthReconcileInput) (*service.GrokOAuthReconcileResult, error) {
|
||||
s.calls++
|
||||
s.input = input
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
u.lastReq = req
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
u.lastBody, _ = io.ReadAll(req.Body)
|
||||
body, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
return u.resp, nil
|
||||
u.mu.Lock()
|
||||
u.requests = append(u.requests, req)
|
||||
u.bodies = append(u.bodies, body)
|
||||
u.mu.Unlock()
|
||||
if req.URL.Path == "/v1/responses" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"X-Ratelimit-Limit-Requests": []string{"10"},
|
||||
"X-Ratelimit-Remaining-Requests": []string{"8"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}, nil
|
||||
}
|
||||
payload := `{"config":{"billingPeriodStart":"2026-07-01T00:00:00Z","billingPeriodEnd":"2026-08-01T00:00:00Z"}}`
|
||||
if req.URL.RawQuery == "format=credits" {
|
||||
payload = `{"config":{"currentPeriod":{"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"}}}`
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(payload))}, nil
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) DoWithTLS(
|
||||
@@ -71,22 +103,18 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
ID: 42,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
}}
|
||||
upstream := &grokQuotaHandlerUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"X-Ratelimit-Limit-Requests": []string{"10"},
|
||||
"X-Ratelimit-Remaining-Requests": []string{"8"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
upstream := &grokQuotaHandlerUpstream{}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
|
||||
@@ -95,12 +123,23 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"source":"active_probe"`)
|
||||
require.Contains(t, rec.Body.String(), `"source":"hybrid_probe"`)
|
||||
require.Contains(t, rec.Body.String(), `"billing":`)
|
||||
require.Contains(t, rec.Body.String(), `"snapshot":`)
|
||||
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
|
||||
require.NotContains(t, rec.Body.String(), "access-token")
|
||||
require.Equal(t, xai.DefaultBaseURL+"/responses", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Contains(t, string(upstream.lastBody), `"store":false`)
|
||||
upstream.mu.Lock()
|
||||
requests := append([]*http.Request(nil), upstream.requests...)
|
||||
bodies := append([][]byte(nil), upstream.bodies...)
|
||||
upstream.mu.Unlock()
|
||||
require.Len(t, requests, 3)
|
||||
for i, upstreamReq := range requests {
|
||||
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
|
||||
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
|
||||
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
|
||||
require.Contains(t, string(bodies[i]), `"store":false`)
|
||||
}
|
||||
}
|
||||
require.NotNil(t, repo.updates[42])
|
||||
}
|
||||
|
||||
@@ -112,8 +151,8 @@ func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
@@ -131,7 +170,7 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
|
||||
t.Setenv(xai.EnvClientID, "client-secret-like-value")
|
||||
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -145,3 +184,117 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
require.NotContains(t, rec.Body.String(), "secret")
|
||||
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
|
||||
}
|
||||
|
||||
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
|
||||
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
|
||||
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
|
||||
ExpiresAt: tokenExpiry,
|
||||
})
|
||||
|
||||
require.NotNil(t, expiresAt)
|
||||
require.Equal(t, tokenExpiry, *expiresAt)
|
||||
require.NotNil(t, autoPause)
|
||||
require.True(t, *autoPause)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportExpiryUsesEarlierRequestedExpiryWithoutRefreshToken(t *testing.T) {
|
||||
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
|
||||
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
|
||||
requestedAutoPause := false
|
||||
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
|
||||
ExpiresAt: tokenExpiry,
|
||||
})
|
||||
|
||||
require.NotNil(t, expiresAt)
|
||||
require.Equal(t, requestedExpiry, *expiresAt)
|
||||
require.NotNil(t, autoPause)
|
||||
require.True(t, *autoPause)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportExpiryPreservesRequestSettingsWithRefreshToken(t *testing.T) {
|
||||
requestedExpiry := time.Now().Add(2 * time.Hour).Unix()
|
||||
requestedAutoPause := false
|
||||
expiresAt, autoPause := grokSSOImportExpiry(&requestedExpiry, &requestedAutoPause, &service.GrokTokenInfo{
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: time.Now().Add(6 * time.Hour).Unix(),
|
||||
})
|
||||
|
||||
require.Same(t, &requestedExpiry, expiresAt)
|
||||
require.Same(t, &requestedAutoPause, autoPause)
|
||||
}
|
||||
|
||||
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
|
||||
h := &GrokOAuthHandler{}
|
||||
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
|
||||
// Without a service, createAccountFromSSOToken would panic on nil service access.
|
||||
// Recovery must convert that into a failed item and keep the worker alive.
|
||||
require.False(t, result.created)
|
||||
require.Equal(t, 2, result.item.Index)
|
||||
require.Contains(t, result.item.Error, "internal worker panic")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{
|
||||
DryRun: true,
|
||||
Scanned: 2,
|
||||
Actionable: 1,
|
||||
WouldBlock: 1,
|
||||
Items: []service.GrokOAuthReconcileItem{{AccountID: 42, Reason: service.GrokOAuthReconcileReasonMissingRefreshToken, Action: service.GrokOAuthReconcileActionBlock, Outcome: service.GrokOAuthReconcileOutcomePlanned}},
|
||||
NextAfterID: 0,
|
||||
}}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, reconciler.calls)
|
||||
require.True(t, reconciler.input.DryRun)
|
||||
require.False(t, reconciler.input.Apply)
|
||||
require.Contains(t, rec.Body.String(), `"reason":"missing_refresh_token"`)
|
||||
require.NotContains(t, rec.Body.String(), `"refresh_token":`)
|
||||
require.NotContains(t, rec.Body.String(), `"access_token":`)
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileRequiresExplicitApply(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"dry_run":false}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Zero(t, reconciler.calls)
|
||||
require.NotContains(t, rec.Body.String(), "credentials")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerReconcileExplicitApply(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
reconciler := &grokOAuthReconcilerStub{result: &service.GrokOAuthReconcileResult{DryRun: false, Refreshed: 1}}
|
||||
handler := NewGrokOAuthHandler(nil, nil, nil, reconciler)
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/oauth/reconcile", handler.ReconcileOAuthAccounts)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/reconcile", strings.NewReader(`{"apply":true,"dry_run":false,"after_id":10,"limit":25,"refresh_window_seconds":3600}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, reconciler.calls)
|
||||
require.True(t, reconciler.input.Apply)
|
||||
require.False(t, reconciler.input.DryRun)
|
||||
require.Equal(t, int64(10), reconciler.input.AfterID)
|
||||
require.Equal(t, 25, reconciler.input.Limit)
|
||||
require.Equal(t, time.Hour, reconciler.input.RefreshWindow)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ type CreateGroupRequest struct {
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
@@ -163,6 +164,7 @@ type UpdateGroupRequest struct {
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
ClaudeCodeOnly *bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
|
||||
@@ -334,6 +336,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
VideoPrice480P: req.VideoPrice480P,
|
||||
VideoPrice720P: req.VideoPrice720P,
|
||||
VideoPrice1080P: req.VideoPrice1080P,
|
||||
WebSearchPricePerCall: req.WebSearchPricePerCall,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -402,6 +405,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
VideoPrice480P: req.VideoPrice480P,
|
||||
VideoPrice720P: req.VideoPrice720P,
|
||||
VideoPrice1080P: req.VideoPrice1080P,
|
||||
WebSearchPricePerCall: req.WebSearchPricePerCall,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
|
||||
|
||||
@@ -37,14 +37,9 @@ func executeAdminIdempotent(
|
||||
return &service.IdempotencyExecuteResult{Data: data}, nil
|
||||
}
|
||||
|
||||
actorScope := "admin:0"
|
||||
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
|
||||
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
|
||||
}
|
||||
|
||||
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
|
||||
Scope: scope,
|
||||
ActorScope: actorScope,
|
||||
ActorScope: adminActorScope(c),
|
||||
Method: c.Request.Method,
|
||||
Route: c.FullPath(),
|
||||
IdempotencyKey: c.GetHeader("Idempotency-Key"),
|
||||
@@ -54,6 +49,14 @@ func executeAdminIdempotent(
|
||||
}, execute)
|
||||
}
|
||||
|
||||
func adminActorScope(c *gin.Context) string {
|
||||
actorScope := "admin:0"
|
||||
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
|
||||
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
|
||||
}
|
||||
return actorScope
|
||||
}
|
||||
|
||||
func executeAdminIdempotentJSON(
|
||||
c *gin.Context,
|
||||
scope string,
|
||||
|
||||
@@ -304,6 +304,10 @@ func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if req.Concurrency != nil && *req.Concurrency < 0 {
|
||||
response.BadRequest(c, "concurrency must be >= 0")
|
||||
return
|
||||
|
||||
@@ -386,8 +386,8 @@ func (h *OpsHandler) ListRequestErrorUpstreamErrors(c *gin.Context) {
|
||||
filter.EndTime = &endTime
|
||||
}
|
||||
filter.View = "all"
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
|
||||
// Provider-health list includes recovered inference and credential rows.
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
@@ -470,8 +470,8 @@ func (h *OpsHandler) ListUpstreamErrors(c *gin.Context) {
|
||||
}
|
||||
|
||||
filter.View = parseOpsViewParam(c)
|
||||
filter.Phase = "upstream"
|
||||
// 上游错误列表需含 status<400 的 recovered 行,显式豁免客户端可见守卫。
|
||||
filter.ErrorPhasesAny = []string{"upstream", "account_auth"}
|
||||
// Provider-health list includes recovered inference and credential rows.
|
||||
filter.IncludeRecoveredUpstream = true
|
||||
filter.Owner = "provider"
|
||||
filter.Source = strings.TrimSpace(c.Query("error_source"))
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type opsSystemLogCleanupRequest struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Host string `json:"host"`
|
||||
|
||||
Level string `json:"level"`
|
||||
Component string `json:"component"`
|
||||
@@ -56,6 +57,7 @@ func (h *OpsHandler) ListSystemLogs(c *gin.Context) {
|
||||
PageSize: pageSize,
|
||||
StartTime: &start,
|
||||
EndTime: &end,
|
||||
Host: strings.TrimSpace(c.Query("host")),
|
||||
Level: strings.TrimSpace(c.Query("level")),
|
||||
Component: strings.TrimSpace(c.Query("component")),
|
||||
RequestID: strings.TrimSpace(c.Query("request_id")),
|
||||
@@ -153,6 +155,7 @@ func (h *OpsHandler) CleanupSystemLogs(c *gin.Context) {
|
||||
filter := &service.OpsSystemLogCleanupFilter{
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Host: strings.TrimSpace(req.Host),
|
||||
Level: strings.TrimSpace(req.Level),
|
||||
Component: strings.TrimSpace(req.Component),
|
||||
RequestID: strings.TrimSpace(req.RequestID),
|
||||
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -19,6 +20,26 @@ type responseEnvelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type opsSystemLogCaptureRepo struct {
|
||||
service.OpsRepository
|
||||
listFilter *service.OpsSystemLogFilter
|
||||
cleanupFilter *service.OpsSystemLogCleanupFilter
|
||||
}
|
||||
|
||||
func (r *opsSystemLogCaptureRepo) ListSystemLogs(_ context.Context, filter *service.OpsSystemLogFilter) (*service.OpsSystemLogList, error) {
|
||||
r.listFilter = filter
|
||||
return &service.OpsSystemLogList{Logs: []*service.OpsSystemLog{}, Page: filter.Page, PageSize: filter.PageSize}, nil
|
||||
}
|
||||
|
||||
func (r *opsSystemLogCaptureRepo) DeleteSystemLogs(_ context.Context, filter *service.OpsSystemLogCleanupFilter) (int64, error) {
|
||||
r.cleanupFilter = filter
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (r *opsSystemLogCaptureRepo) InsertSystemLogCleanupAudit(_ context.Context, _ *service.OpsSystemLogCleanupAudit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOpsSystemLogTestRouter(handler *OpsHandler, withUser bool) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -121,6 +142,23 @@ func TestOpsSystemLogHandler_ListSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_ListAcceptsHost(t *testing.T) {
|
||||
repo := &opsSystemLogCaptureRepo{}
|
||||
svc := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
r := newOpsSystemLogTestRouter(h, false)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logs?host=api-node-1", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200", w.Code)
|
||||
}
|
||||
if repo.listFilter == nil || repo.listFilter.Host != "api-node-1" {
|
||||
t.Fatalf("host filter = %+v, want api-node-1", repo.listFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupUnauthorized(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
@@ -205,6 +243,24 @@ func TestOpsSystemLogHandler_CleanupAcceptsAPIKeyID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupAcceptsHost(t *testing.T) {
|
||||
repo := &opsSystemLogCaptureRepo{}
|
||||
svc := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
r := newOpsSystemLogTestRouter(h, true)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/logs/cleanup", bytes.NewBufferString(`{"host":"api-node-1"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200", w.Code)
|
||||
}
|
||||
if repo.cleanupFilter == nil || repo.cleanupFilter.Host != "api-node-1" {
|
||||
t.Fatalf("host filter = %+v, want api-node-1", repo.cleanupFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsSystemLogHandler_CleanupInvalidAPIKeyID(t *testing.T) {
|
||||
svc := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h := NewOpsHandler(svc)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -323,7 +324,7 @@ func (h *OpsHandler) QPSWSHandler(c *gin.Context) {
|
||||
// If realtime monitoring is disabled, prefer a successful WS upgrade followed by a clean close
|
||||
// with a deterministic close code. This prevents clients from spinning on 404/1006 reconnect loops.
|
||||
if !h.opsService.IsRealtimeMonitoringEnabled(c.Request.Context()) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, servermiddleware.ServerTimingResponseHeader(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "ops realtime monitoring is disabled"})
|
||||
return
|
||||
@@ -358,7 +359,7 @@ func (h *OpsHandler) QPSWSHandler(c *gin.Context) {
|
||||
defer releaseOpsWSIPSlot(clientIP)
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, servermiddleware.ServerTimingResponseHeader(c))
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("handler.admin.ops_ws", "[OpsWS] upgrade failed: %v", err)
|
||||
return
|
||||
|
||||
@@ -123,6 +123,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
SessionBindingEnabled: settings.SessionBindingEnabled,
|
||||
AuditLogRetentionDays: settings.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
@@ -229,6 +231,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: settings.AdminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: settings.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: settings.EnableModelFallback,
|
||||
@@ -268,6 +271,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: settings.PaymentVisibleMethodAlipayEnabled,
|
||||
PaymentVisibleMethodWxpayEnabled: settings.PaymentVisibleMethodWxpayEnabled,
|
||||
OpenAILowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
|
||||
OpenAIOAuthSchedulingRateMultiplier: settings.OpenAIOAuthSchedulingRateMultiplier,
|
||||
OpenAIAdvancedSchedulerEnabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
@@ -279,6 +284,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
OpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: settings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK: settings.OpenAIAdvancedSchedulerEffectiveLBTopK,
|
||||
@@ -289,6 +295,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT: settings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset: settings.OpenAIAdvancedSchedulerEffectiveWeightReset,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: settings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: settings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: settings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
|
||||
@@ -308,6 +308,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
|
||||
changed = append(changed, "affiliate_rebate_per_invitee_cap")
|
||||
}
|
||||
if before.AdminRechargeRebateEnabled != after.AdminRechargeRebateEnabled {
|
||||
changed = append(changed, "affiliate_admin_recharge_enabled")
|
||||
}
|
||||
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
|
||||
changed = append(changed, "default_subscriptions")
|
||||
}
|
||||
@@ -437,6 +440,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled {
|
||||
changed = append(changed, "payment_visible_method_wxpay_enabled")
|
||||
}
|
||||
if before.OpenAILowUpstreamRatePriorityEnabled != after.OpenAILowUpstreamRatePriorityEnabled {
|
||||
changed = append(changed, "openai_low_upstream_rate_priority_enabled")
|
||||
}
|
||||
if before.OpenAIOAuthSchedulingRateMultiplier != after.OpenAIOAuthSchedulingRateMultiplier {
|
||||
changed = append(changed, "openai_oauth_scheduling_rate_multiplier")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_enabled")
|
||||
}
|
||||
@@ -470,6 +479,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightUpstreamCost != after.OpenAIAdvancedSchedulerWeightUpstreamCost {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_upstream_cost")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_previous_response")
|
||||
}
|
||||
|
||||
@@ -223,6 +223,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
"payment_visible_method_alipay_enabled": true,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"openai_oauth_scheduling_rate_multiplier": 0.05,
|
||||
"openai_advanced_scheduler_subscription_priority_enabled": true,
|
||||
}
|
||||
rawBody, err := json.Marshal(body)
|
||||
@@ -241,6 +242,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, "true", repo.values[service.SettingPaymentVisibleMethodAlipayEnabled])
|
||||
require.Equal(t, "false", repo.values[service.SettingPaymentVisibleMethodWxpayEnabled])
|
||||
require.Equal(t, "true", repo.values["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, "0.05", repo.values[service.SettingKeyOpenAIOAuthSchedulingRateMultiplier])
|
||||
require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled])
|
||||
|
||||
var resp response.Response
|
||||
@@ -252,6 +254,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS
|
||||
require.Equal(t, true, data["payment_visible_method_alipay_enabled"])
|
||||
require.Equal(t, false, data["payment_visible_method_wxpay_enabled"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_enabled"])
|
||||
require.Equal(t, 0.05, data["openai_oauth_scheduling_rate_multiplier"])
|
||||
require.Equal(t, true, data["openai_advanced_scheduler_subscription_priority_enabled"])
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ type UpdateSettingsRequest struct {
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
FrontendURL string `json:"frontend_url"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -149,6 +151,7 @@ type UpdateSettingsRequest struct {
|
||||
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
|
||||
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
|
||||
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
|
||||
AdminRechargeRebateEnabled *bool `json:"affiliate_admin_recharge_enabled"`
|
||||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
||||
@@ -242,19 +245,22 @@ type UpdateSettingsRequest struct {
|
||||
PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
|
||||
|
||||
// OpenAI account scheduling
|
||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
OpenAILowUpstreamRatePriorityEnabled *bool `json:"openai_low_upstream_rate_priority_enabled"`
|
||||
OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"`
|
||||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost *string `json:"openai_advanced_scheduler_weight_upstream_cost"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
|
||||
// 余额不足提醒
|
||||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||||
@@ -391,6 +397,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if affiliateRebatePerInviteeCap < 0 {
|
||||
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
adminRechargeRebateEnabled := previousSettings.AdminRechargeRebateEnabled
|
||||
if req.AdminRechargeRebateEnabled != nil {
|
||||
adminRechargeRebateEnabled = *req.AdminRechargeRebateEnabled
|
||||
}
|
||||
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
|
||||
if req.TableDefaultPageSize <= 0 {
|
||||
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
|
||||
@@ -1171,6 +1181,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
FrontendURL: req.FrontendURL,
|
||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||
TotpEnabled: req.TotpEnabled,
|
||||
SessionBindingEnabled: req.SessionBindingEnabled,
|
||||
AuditLogRetentionDays: req.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: req.LoginAgreementEnabled,
|
||||
LoginAgreementMode: loginAgreementMode,
|
||||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||
@@ -1279,6 +1291,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: affiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: adminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
EnableModelFallback: req.EnableModelFallback,
|
||||
@@ -1423,6 +1436,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.PaymentVisibleMethodWxpayEnabled
|
||||
}(),
|
||||
OpenAILowUpstreamRatePriorityEnabled: func() bool {
|
||||
if req.OpenAILowUpstreamRatePriorityEnabled != nil {
|
||||
return *req.OpenAILowUpstreamRatePriorityEnabled
|
||||
}
|
||||
return previousSettings.OpenAILowUpstreamRatePriorityEnabled
|
||||
}(),
|
||||
OpenAIOAuthSchedulingRateMultiplier: func() float64 {
|
||||
if req.OpenAIOAuthSchedulingRateMultiplier != nil {
|
||||
return *req.OpenAIOAuthSchedulingRateMultiplier
|
||||
}
|
||||
return previousSettings.OpenAIOAuthSchedulingRateMultiplier
|
||||
}(),
|
||||
OpenAIAdvancedSchedulerEnabled: func() bool {
|
||||
if req.OpenAIAdvancedSchedulerEnabled != nil {
|
||||
return *req.OpenAIAdvancedSchedulerEnabled
|
||||
@@ -1449,6 +1474,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT),
|
||||
OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset),
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom),
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: stringSetting(req.OpenAIAdvancedSchedulerWeightUpstreamCost, previousSettings.OpenAIAdvancedSchedulerWeightUpstreamCost),
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse),
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky),
|
||||
BalanceLowNotifyEnabled: func() bool {
|
||||
@@ -1683,6 +1709,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||
TotpEnabled: updatedSettings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
|
||||
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
|
||||
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: updatedSettings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
|
||||
@@ -1786,6 +1814,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
|
||||
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
|
||||
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
|
||||
AdminRechargeRebateEnabled: updatedSettings.AdminRechargeRebateEnabled,
|
||||
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
|
||||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||||
@@ -1824,6 +1853,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||||
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
|
||||
OpenAILowUpstreamRatePriorityEnabled: updatedSettings.OpenAILowUpstreamRatePriorityEnabled,
|
||||
OpenAIOAuthSchedulingRateMultiplier: updatedSettings.OpenAIOAuthSchedulingRateMultiplier,
|
||||
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
@@ -1835,6 +1866,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset,
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK,
|
||||
@@ -1845,6 +1877,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
|
||||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,8 @@ type UserHandler struct {
|
||||
concurrencyService *service.ConcurrencyService
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view
|
||||
billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径)
|
||||
totpService *service.TotpService // 角色提升为管理员的 step-up 门控
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new admin user handler
|
||||
@@ -38,12 +41,16 @@ func NewUserHandler(
|
||||
concurrencyService *service.ConcurrencyService,
|
||||
userPlatformQuotaRepo service.UserPlatformQuotaRepository,
|
||||
billingCache service.BillingCache,
|
||||
totpService *service.TotpService,
|
||||
userService *service.UserService,
|
||||
) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
concurrencyService: concurrencyService,
|
||||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||||
billingCache: billingCache,
|
||||
totpService: totpService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +273,13 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
if req.Role == service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
@@ -308,6 +322,21 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。
|
||||
// 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。
|
||||
if req.Role == service.RoleAdmin {
|
||||
target, err := h.adminService.GetUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if target.Role != service.RoleAdmin {
|
||||
if !middleware.EnforceStepUp(c, h.totpService, h.userService) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用指针类型直接传递,nil 表示未提供该字段
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
@@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) {
|
||||
UpdatedAt: lastLoginAt,
|
||||
},
|
||||
}
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil)
|
||||
handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (*
|
||||
func setupGetByIDRouter(svc service.AdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h := NewUserHandler(svc, nil, nil, nil)
|
||||
h := NewUserHandler(svc, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users/:id", h.GetByID)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stub := &listUsersFilterStub{AdminService: newStubAdminService()}
|
||||
r := gin.New()
|
||||
h := NewUserHandler(stub, nil, nil, nil)
|
||||
h := NewUserHandler(stub, nil, nil, nil, nil, nil)
|
||||
r.GET("/admin/users", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 角色提升为管理员的 step-up 门控条件测试。
|
||||
// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止;
|
||||
// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。
|
||||
func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
adminSvc := newStubAdminService()
|
||||
// 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。
|
||||
adminSvc.users = append(adminSvc.users, service.User{
|
||||
ID: 2,
|
||||
Email: "admin@example.com",
|
||||
Role: service.RoleAdmin,
|
||||
Status: service.StatusActive,
|
||||
})
|
||||
|
||||
h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/users", h.Create)
|
||||
router.PUT("/api/v1/admin/users/:id", h.Update)
|
||||
return router, adminSvc
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRequiresStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-admin@example.com", "password": "pass123", "role": "admin",
|
||||
})
|
||||
require.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
|
||||
func TestCreateRegularUserSkipsStepUp(t *testing.T) {
|
||||
router, _ := setupRoleStepUpRouter(t)
|
||||
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{
|
||||
"email": "new-user@example.com", "password": "pass123", "role": "user",
|
||||
})
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
|
||||
if err != nil {
|
||||
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
|
||||
// 回退到只返回Access Token
|
||||
token, tokenErr := h.authService.GenerateToken(user)
|
||||
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
|
||||
if tokenErr != nil {
|
||||
response.InternalError(c, "Failed to generate token")
|
||||
return
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
||||
Save(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := handler.authService.GenerateToken(&service.User{
|
||||
token, err := handler.authService.GenerateToken(context.Background(), &service.User{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
|
||||
@@ -3360,6 +3360,14 @@ func (s *oauthPendingFlowTotpCacheStub) ClearVerifyAttempts(_ context.Context, u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowTotpCacheStub) SetStepUpGrant(_ context.Context, _ int64, _ string, _ time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *oauthPendingFlowTotpCacheStub) HasStepUpGrant(_ context.Context, _ int64, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type oauthPendingFlowTotpEncryptorStub struct{}
|
||||
|
||||
func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, error) {
|
||||
|
||||
@@ -72,6 +72,7 @@ type userSupportedModelPricing struct {
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
@@ -284,6 +285,7 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestChatCompletionsRejectsGPTImageModelsBeforeScheduling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, model := range []string{"gpt-image-1", "gpt-image-1.5", "gpt-image-2"} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
call func(*gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "gateway",
|
||||
call: (&GatewayHandler{}).ChatCompletions,
|
||||
},
|
||||
{
|
||||
name: "openai_gateway",
|
||||
call: newOpenAIImageChatRejectionHandler(t).ChatCompletions,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"/"+model, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"draw"}]}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
tc.call(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "Chat Completions")
|
||||
_, selected := c.Get(opsAccountIDKey)
|
||||
require.False(t, selected, "rejection must happen before account selection")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIChatCompletionsImageModelRejectionDoesNotAcquireConcurrency(t *testing.T) {
|
||||
var acquireCalls atomic.Int64
|
||||
cache := &concurrencyCacheMock{
|
||||
acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
|
||||
acquireCalls.Add(1)
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
h := newOpenAIImageChatRejectionHandlerWithCache(t, cache)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(
|
||||
`{"model":"gpt-image-2","messages":[{"role":"user","content":"draw"}]}`,
|
||||
))
|
||||
setImageChatTestAuth(c)
|
||||
|
||||
h.ChatCompletions(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Zero(t, acquireCalls.Load(), "rejection must happen before user/account concurrency and scheduling")
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandler(t *testing.T) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return newOpenAIImageChatRejectionHandlerWithCache(t, &concurrencyCacheMock{})
|
||||
}
|
||||
|
||||
func newOpenAIImageChatRejectionHandlerWithCache(t *testing.T, cache *concurrencyCacheMock) *OpenAIGatewayHandler {
|
||||
t.Helper()
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: &service.OpenAIGatewayService{},
|
||||
billingCacheService: &service.BillingCacheService{},
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func setImageChatTestAuth(c *gin.Context) {
|
||||
apiKey := &service.APIKey{ID: 4348, UserID: 4348, User: &service.User{ID: 4348}}
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1})
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
"aws_secret_access_key": "aws-secret",
|
||||
"service_account_json": map[string]any{"private_key": "..."},
|
||||
"private_key": "raw-key",
|
||||
"agent_private_key": "agent-key-secret",
|
||||
// 非敏感
|
||||
"base_url": "https://api.example.com",
|
||||
"model_mapping": map[string]any{"foo": "bar"},
|
||||
@@ -35,6 +36,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
require.NotContains(t, out, "aws_secret_access_key")
|
||||
require.NotContains(t, out, "service_account_json")
|
||||
require.NotContains(t, out, "private_key")
|
||||
require.NotContains(t, out, "agent_private_key")
|
||||
|
||||
require.Equal(t, "https://api.example.com", out["base_url"])
|
||||
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
|
||||
@@ -47,6 +49,7 @@ func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
|
||||
require.True(t, status["has_aws_secret_access_key"])
|
||||
require.True(t, status["has_service_account_json"])
|
||||
require.True(t, status["has_private_key"])
|
||||
require.True(t, status["has_agent_private_key"])
|
||||
|
||||
// 状态 map 不应携带非敏感键的 has_*
|
||||
require.NotContains(t, status, "has_base_url")
|
||||
@@ -84,6 +87,7 @@ func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) {
|
||||
"api_key", "session_key", "cookie",
|
||||
"aws_secret_access_key", "aws_session_token",
|
||||
"service_account_json", "service_account", "private_key",
|
||||
"agent_private_key",
|
||||
}
|
||||
in := make(map[string]any, len(keys))
|
||||
for _, k := range keys {
|
||||
|
||||
@@ -199,6 +199,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
VideoPrice480P: g.VideoPrice480P,
|
||||
VideoPrice720P: g.VideoPrice720P,
|
||||
VideoPrice1080P: g.VideoPrice1080P,
|
||||
WebSearchPricePerCall: g.WebSearchPricePerCall,
|
||||
ClaudeCodeOnly: g.ClaudeCodeOnly,
|
||||
FallbackGroupID: g.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
|
||||
@@ -598,54 +599,57 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
requestedModel = l.Model
|
||||
}
|
||||
return UsageLog{
|
||||
ID: l.ID,
|
||||
UserID: l.UserID,
|
||||
APIKeyID: l.APIKeyID,
|
||||
AccountID: l.AccountID,
|
||||
RequestID: l.RequestID,
|
||||
Model: requestedModel,
|
||||
ServiceTier: l.ServiceTier,
|
||||
ReasoningEffort: l.ReasoningEffort,
|
||||
InboundEndpoint: l.InboundEndpoint,
|
||||
GroupID: l.GroupID,
|
||||
SubscriptionID: l.SubscriptionID,
|
||||
InputTokens: l.InputTokens,
|
||||
OutputTokens: l.OutputTokens,
|
||||
CacheCreationTokens: l.CacheCreationTokens,
|
||||
CacheReadTokens: l.CacheReadTokens,
|
||||
CacheCreation5mTokens: l.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: l.CacheCreation1hTokens,
|
||||
InputCost: l.InputCost,
|
||||
OutputCost: l.OutputCost,
|
||||
CacheCreationCost: l.CacheCreationCost,
|
||||
CacheReadCost: l.CacheReadCost,
|
||||
TotalCost: l.TotalCost,
|
||||
ActualCost: l.ActualCost,
|
||||
RateMultiplier: l.RateMultiplier,
|
||||
BillingType: l.BillingType,
|
||||
RequestType: requestType.String(),
|
||||
Stream: stream,
|
||||
OpenAIWSMode: openAIWSMode,
|
||||
DurationMs: l.DurationMs,
|
||||
FirstTokenMs: l.FirstTokenMs,
|
||||
ImageCount: l.ImageCount,
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageOutputTokens: l.ImageOutputTokens,
|
||||
ImageOutputCost: l.ImageOutputCost,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
ImageSizeBreakdown: l.ImageSizeBreakdown,
|
||||
MediaType: l.MediaType,
|
||||
UserAgent: l.UserAgent,
|
||||
IPAddress: l.IPAddress,
|
||||
CacheTTLOverridden: l.CacheTTLOverridden,
|
||||
BillingMode: l.BillingMode,
|
||||
CreatedAt: l.CreatedAt,
|
||||
User: UserFromServiceShallow(l.User),
|
||||
APIKey: APIKeyFromService(l.APIKey),
|
||||
Group: GroupFromServiceShallow(l.Group),
|
||||
Subscription: UserSubscriptionFromService(l.Subscription),
|
||||
ID: l.ID,
|
||||
UserID: l.UserID,
|
||||
APIKeyID: l.APIKeyID,
|
||||
AccountID: l.AccountID,
|
||||
RequestID: l.RequestID,
|
||||
Model: requestedModel,
|
||||
ServiceTier: l.ServiceTier,
|
||||
ReasoningEffort: l.ReasoningEffort,
|
||||
InboundEndpoint: l.InboundEndpoint,
|
||||
GroupID: l.GroupID,
|
||||
SubscriptionID: l.SubscriptionID,
|
||||
InputTokens: l.InputTokens,
|
||||
OutputTokens: l.OutputTokens,
|
||||
CacheCreationTokens: l.CacheCreationTokens,
|
||||
CacheReadTokens: l.CacheReadTokens,
|
||||
CacheCreation5mTokens: l.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: l.CacheCreation1hTokens,
|
||||
InputCost: l.InputCost,
|
||||
OutputCost: l.OutputCost,
|
||||
CacheCreationCost: l.CacheCreationCost,
|
||||
CacheReadCost: l.CacheReadCost,
|
||||
TotalCost: l.TotalCost,
|
||||
ActualCost: l.ActualCost,
|
||||
RateMultiplier: l.RateMultiplier,
|
||||
LongContextBillingApplied: l.LongContextBillingApplied,
|
||||
BillingType: l.BillingType,
|
||||
RequestType: requestType.String(),
|
||||
Stream: stream,
|
||||
OpenAIWSMode: openAIWSMode,
|
||||
DurationMs: l.DurationMs,
|
||||
FirstTokenMs: l.FirstTokenMs,
|
||||
ImageCount: l.ImageCount,
|
||||
ImageSize: l.ImageSize,
|
||||
ImageInputSize: l.ImageInputSize,
|
||||
ImageOutputSize: l.ImageOutputSize,
|
||||
ImageInputTokens: l.ImageInputTokens,
|
||||
ImageInputCost: l.ImageInputCost,
|
||||
ImageOutputTokens: l.ImageOutputTokens,
|
||||
ImageOutputCost: l.ImageOutputCost,
|
||||
ImageSizeSource: l.ImageSizeSource,
|
||||
ImageSizeBreakdown: l.ImageSizeBreakdown,
|
||||
MediaType: l.MediaType,
|
||||
UserAgent: l.UserAgent,
|
||||
IPAddress: l.IPAddress,
|
||||
CacheTTLOverridden: l.CacheTTLOverridden,
|
||||
BillingMode: l.BillingMode,
|
||||
CreatedAt: l.CreatedAt,
|
||||
User: UserFromServiceShallow(l.User),
|
||||
APIKey: APIKeyFromService(l.APIKey),
|
||||
Group: GroupFromServiceShallow(l.Group),
|
||||
Subscription: UserSubscriptionFromService(l.Subscription),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ type SystemSettings struct {
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
|
||||
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -148,6 +150,7 @@ type SystemSettings struct {
|
||||
AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"`
|
||||
AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"`
|
||||
AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"`
|
||||
AdminRechargeRebateEnabled bool `json:"affiliate_admin_recharge_enabled"`
|
||||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||||
DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||||
|
||||
@@ -208,29 +211,33 @@ type SystemSettings struct {
|
||||
PaymentVisibleMethodWxpayEnabled bool `json:"payment_visible_method_wxpay_enabled"`
|
||||
|
||||
// OpenAI account scheduling
|
||||
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
|
||||
OpenAILowUpstreamRatePriorityEnabled bool `json:"openai_low_upstream_rate_priority_enabled"`
|
||||
OpenAIOAuthSchedulingRateMultiplier float64 `json:"openai_oauth_scheduling_rate_multiplier"`
|
||||
OpenAIAdvancedSchedulerEnabled bool `json:"openai_advanced_scheduler_enabled"`
|
||||
OpenAIAdvancedSchedulerStickyWeightedEnabled bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||||
OpenAIAdvancedSchedulerLBTopK string `json:"openai_advanced_scheduler_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerWeightPriority string `json:"openai_advanced_scheduler_weight_priority"`
|
||||
OpenAIAdvancedSchedulerWeightLoad string `json:"openai_advanced_scheduler_weight_load"`
|
||||
OpenAIAdvancedSchedulerWeightQueue string `json:"openai_advanced_scheduler_weight_queue"`
|
||||
OpenAIAdvancedSchedulerWeightErrorRate string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerWeightTTFT string `json:"openai_advanced_scheduler_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerWeightReset string `json:"openai_advanced_scheduler_weight_reset"`
|
||||
OpenAIAdvancedSchedulerWeightQuotaHeadroom string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerWeightUpstreamCost string `json:"openai_advanced_scheduler_weight_upstream_cost"`
|
||||
OpenAIAdvancedSchedulerWeightPreviousResponse string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerWeightSessionSticky string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||||
OpenAIAdvancedSchedulerEffectiveLBTopK string `json:"openai_advanced_scheduler_effective_lb_top_k"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPriority string `json:"openai_advanced_scheduler_effective_weight_priority"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightLoad string `json:"openai_advanced_scheduler_effective_weight_load"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQueue string `json:"openai_advanced_scheduler_effective_weight_queue"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightErrorRate string `json:"openai_advanced_scheduler_effective_weight_error_rate"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightTTFT string `json:"openai_advanced_scheduler_effective_weight_ttft"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightReset string `json:"openai_advanced_scheduler_effective_weight_reset"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom string `json:"openai_advanced_scheduler_effective_weight_quota_headroom"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost string `json:"openai_advanced_scheduler_effective_weight_upstream_cost"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse string `json:"openai_advanced_scheduler_effective_weight_previous_response"`
|
||||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky string `json:"openai_advanced_scheduler_effective_weight_session_sticky"`
|
||||
|
||||
// Payment configuration
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
|
||||
@@ -120,6 +120,8 @@ type Group struct {
|
||||
VideoPrice480P *float64 `json:"video_price_480p"`
|
||||
VideoPrice720P *float64 `json:"video_price_720p"`
|
||||
VideoPrice1080P *float64 `json:"video_price_1080p"`
|
||||
// Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
|
||||
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
|
||||
|
||||
// Claude Code 客户端限制
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
@@ -480,13 +482,14 @@ type UsageLog struct {
|
||||
CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
|
||||
CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
|
||||
|
||||
InputCost float64 `json:"input_cost"`
|
||||
OutputCost float64 `json:"output_cost"`
|
||||
CacheCreationCost float64 `json:"cache_creation_cost"`
|
||||
CacheReadCost float64 `json:"cache_read_cost"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
InputCost float64 `json:"input_cost"`
|
||||
OutputCost float64 `json:"output_cost"`
|
||||
CacheCreationCost float64 `json:"cache_creation_cost"`
|
||||
CacheReadCost float64 `json:"cache_read_cost"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
LongContextBillingApplied bool `json:"long_context_billing_applied"`
|
||||
|
||||
BillingType int8 `json:"billing_type"`
|
||||
RequestType string `json:"request_type"`
|
||||
@@ -500,6 +503,8 @@ type UsageLog struct {
|
||||
ImageSize *string `json:"image_size"`
|
||||
ImageInputSize *string `json:"image_input_size"`
|
||||
ImageOutputSize *string `json:"image_output_size"`
|
||||
ImageInputTokens int `json:"image_input_tokens"`
|
||||
ImageInputCost float64 `json:"image_input_cost"`
|
||||
ImageOutputTokens int `json:"image_output_tokens"`
|
||||
ImageOutputCost float64 `json:"image_output_cost"`
|
||||
ImageSizeSource *string `json:"image_size_source"`
|
||||
|
||||
@@ -18,11 +18,15 @@ const (
|
||||
EndpointMessages = "/v1/messages"
|
||||
EndpointChatCompletions = "/v1/chat/completions"
|
||||
EndpointEmbeddings = "/v1/embeddings"
|
||||
EndpointAlphaSearch = "/v1/alpha/search"
|
||||
EndpointResponses = "/v1/responses"
|
||||
EndpointResponsesCompact = "/v1/responses/compact"
|
||||
EndpointImagesGenerations = "/v1/images/generations"
|
||||
EndpointImagesEdits = "/v1/images/edits"
|
||||
EndpointImageTasks = "/v1/images/tasks"
|
||||
EndpointVideosGenerations = "/v1/videos/generations"
|
||||
EndpointVideosEdits = "/v1/videos/edits"
|
||||
EndpointVideosExtensions = "/v1/videos/extensions"
|
||||
EndpointVideos = "/v1/videos"
|
||||
EndpointGeminiModels = "/v1beta/models"
|
||||
)
|
||||
@@ -75,6 +79,8 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
switch {
|
||||
case strings.Contains(path, EndpointEmbeddings):
|
||||
return EndpointEmbeddings
|
||||
case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
|
||||
return EndpointAlphaSearch
|
||||
case strings.Contains(path, EndpointChatCompletions):
|
||||
return EndpointChatCompletions
|
||||
case strings.Contains(path, EndpointMessages):
|
||||
@@ -83,8 +89,14 @@ func NormalizeInboundEndpoint(path string) string {
|
||||
return EndpointImagesGenerations
|
||||
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
|
||||
return EndpointImagesEdits
|
||||
case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"):
|
||||
return EndpointImageTasks
|
||||
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
|
||||
return EndpointVideosGenerations
|
||||
case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"):
|
||||
return EndpointVideosEdits
|
||||
case strings.Contains(path, EndpointVideosExtensions) || strings.Contains(path, "/videos/extensions"):
|
||||
return EndpointVideosExtensions
|
||||
case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
|
||||
return EndpointVideos
|
||||
case strings.Contains(path, EndpointResponsesCompact) || isResponsesCompactAliasPath(path):
|
||||
@@ -155,8 +167,11 @@ func isBareOrSubpathOf(path, root string) bool {
|
||||
// account platform and the normalized inbound endpoint.
|
||||
//
|
||||
// Platform-specific rules:
|
||||
// - OpenAI always forwards to /v1/responses (with optional subpath
|
||||
// such as /v1/responses/compact preserved from the raw URL).
|
||||
// - OpenAI and Grok text compatibility routes forward to /v1/responses
|
||||
// (with optional subpath such as /v1/responses/compact preserved from
|
||||
// the raw URL); native endpoints such as embeddings and alpha search
|
||||
// retain their paths. Grok raw Chat requests override this through the
|
||||
// forwarding result consumed by resolveOpenAIUpstreamEndpoint.
|
||||
// - Anthropic → /v1/messages
|
||||
// - Gemini → /v1beta/models
|
||||
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
|
||||
@@ -167,7 +182,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||
|
||||
switch platform {
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
|
||||
return inbound
|
||||
}
|
||||
// OpenAI forwards everything to the Responses API.
|
||||
|
||||
@@ -25,11 +25,13 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
{"/v1/messages", EndpointMessages},
|
||||
{"/v1/chat/completions", EndpointChatCompletions},
|
||||
{"/v1/embeddings", EndpointEmbeddings},
|
||||
{"/v1/alpha/search", EndpointAlphaSearch},
|
||||
{"/v1/responses", EndpointResponses},
|
||||
{"/v1/responses/compact", EndpointResponsesCompact},
|
||||
{"/v1/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/v1/images/generations", EndpointImagesGenerations},
|
||||
{"/v1/images/edits", EndpointImagesEdits},
|
||||
{"/v1/images/tasks/imgtask_123", EndpointImageTasks},
|
||||
{"/v1/videos/generations", EndpointVideosGenerations},
|
||||
{"/v1/videos/req_123", EndpointVideos},
|
||||
{"/v1beta/models", EndpointGeminiModels},
|
||||
@@ -50,11 +52,14 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
||||
{"/responses", EndpointResponses},
|
||||
{"/responses/compact", EndpointResponsesCompact},
|
||||
{"/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/alpha/search", EndpointAlphaSearch},
|
||||
{"/images/tasks/imgtask_123", EndpointImageTasks},
|
||||
|
||||
// Bare Codex direct alias route — root vs. compact.
|
||||
{"/backend-api/codex/responses", EndpointResponses},
|
||||
{"/backend-api/codex/responses/compact", EndpointResponsesCompact},
|
||||
{"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
|
||||
{"/backend-api/codex/alpha/search", EndpointAlphaSearch},
|
||||
|
||||
// Must NOT generalize to arbitrary paths merely ending in
|
||||
// "/responses" (or "/responses/compact") that are unrelated to
|
||||
@@ -119,8 +124,11 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||
{"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
|
||||
{"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
|
||||
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
|
||||
{"openai alpha search", EndpointAlphaSearch, "/backend-api/codex/alpha/search", service.PlatformOpenAI, EndpointAlphaSearch},
|
||||
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
|
||||
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
|
||||
{"grok chat defaults to responses without runtime result", EndpointChatCompletions, "/v1/chat/completions", service.PlatformGrok, EndpointResponses},
|
||||
{"grok responses", EndpointResponses, "/v1/responses", service.PlatformGrok, EndpointResponses},
|
||||
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
|
||||
{"grok video status", EndpointVideos, "/videos/req_123", service.PlatformGrok, EndpointVideos},
|
||||
|
||||
@@ -138,6 +146,59 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *service.Account
|
||||
result *service.OpenAIForwardResult
|
||||
runtimeEndpoint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "grok raw chat result overrides stale context",
|
||||
account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
|
||||
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointChatCompletions},
|
||||
runtimeEndpoint: EndpointResponses,
|
||||
want: EndpointChatCompletions,
|
||||
},
|
||||
{
|
||||
name: "grok chat bridged to responses",
|
||||
account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
|
||||
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
|
||||
want: EndpointResponses,
|
||||
},
|
||||
{
|
||||
name: "grok empty result keeps responses default",
|
||||
account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
|
||||
result: &service.OpenAIForwardResult{},
|
||||
want: EndpointResponses,
|
||||
},
|
||||
{
|
||||
name: "grok raw error uses runtime endpoint",
|
||||
account: &service.Account{Platform: service.PlatformGrok, Type: service.AccountTypeOAuth},
|
||||
runtimeEndpoint: EndpointChatCompletions,
|
||||
want: EndpointChatCompletions,
|
||||
},
|
||||
{
|
||||
name: "openai behavior remains responses",
|
||||
account: &service.Account{Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
|
||||
result: &service.OpenAIForwardResult{},
|
||||
want: EndpointResponses,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
|
||||
c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
|
||||
service.SetActualOpenAIUpstreamEndpoint(c, tt.runtimeEndpoint)
|
||||
require.Equal(t, tt.want, resolveOpenAIUpstreamEndpoint(c, tt.account, tt.result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// responsesSubpathSuffix
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"go.uber.org/zap"
|
||||
@@ -29,7 +31,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
// maxSameAccountRetries 同账号重试次数上限(针对 RetryableOnSameAccount 错误)
|
||||
// maxSameAccountRetries 同账号重试次数默认上限(针对 RetryableOnSameAccount 错误)。
|
||||
// 生产调用方通常传入账号级配置 account.GetPoolModeRetryCount(),该常量仅作兜底/测试默认值。
|
||||
maxSameAccountRetries = 3
|
||||
// sameAccountRetryDelay 同账号重试间隔
|
||||
sameAccountRetryDelay = 500 * time.Millisecond
|
||||
@@ -67,23 +70,33 @@ func (s *FailoverState) HandleFailoverError(
|
||||
gatewayService TempUnscheduler,
|
||||
accountID int64,
|
||||
platform string,
|
||||
retryLimit int,
|
||||
failoverErr *service.UpstreamFailoverError,
|
||||
) FailoverAction {
|
||||
// 客户端已断开:failover 只会用已取消的 context 重新选号并必然失败,
|
||||
// 不应再被当成账号耗尽处理(误报 502)。
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return FailoverCanceled
|
||||
}
|
||||
s.LastFailoverErr = failoverErr
|
||||
if failoverErr == nil || !failoverErr.ShouldRetryNextAccount() {
|
||||
return FailoverExhausted
|
||||
}
|
||||
|
||||
// 缓存计费判断
|
||||
if needForceCacheBilling(s.hasBoundSession, failoverErr) {
|
||||
s.ForceCacheBilling = true
|
||||
}
|
||||
|
||||
// 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试
|
||||
if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < maxSameAccountRetries {
|
||||
// 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。
|
||||
// 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。
|
||||
if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit {
|
||||
s.SameAccountRetryCount[accountID]++
|
||||
logger.FromContext(ctx).Warn("gateway.failover_same_account_retry",
|
||||
zap.Int64("account_id", accountID),
|
||||
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||
zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]),
|
||||
zap.Int("same_account_retry_max", maxSameAccountRetries),
|
||||
zap.Int("same_account_retry_max", retryLimit),
|
||||
)
|
||||
if !sleepWithContext(ctx, sameAccountRetryDelay) {
|
||||
return FailoverCanceled
|
||||
@@ -132,6 +145,12 @@ func (s *FailoverState) HandleFailoverError(
|
||||
// 返回 FailoverExhausted 时,调用方应返回错误响应。
|
||||
// 返回 FailoverCanceled 时,调用方应直接 return。
|
||||
func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAction {
|
||||
// 客户端已断开时选号失败是 context canceled 的必然结果,
|
||||
// 不代表账号耗尽,直接按取消终止。
|
||||
if ctx.Err() != nil {
|
||||
return FailoverCanceled
|
||||
}
|
||||
|
||||
if s.LastFailoverErr != nil &&
|
||||
s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable &&
|
||||
s.SwitchCount <= s.MaxSwitches {
|
||||
@@ -160,6 +179,28 @@ func needForceCacheBilling(hasBoundSession bool, failoverErr *service.UpstreamFa
|
||||
return hasBoundSession || (failoverErr != nil && failoverErr.ForceCacheBilling)
|
||||
}
|
||||
|
||||
// failoverClientGone 判断下游客户端是否已断开(请求 context 已取消)。
|
||||
// 客户端断开后 failover 必须静默终止:用已取消的 context 重新选号只会得到
|
||||
// context.Canceled,并被误报成账号耗尽(通用 502);上游 detach 的在途请求
|
||||
// 照常完成计费,但不再为无人接收的响应启动新的上游尝试。
|
||||
// 响应尚未提交时把状态码标记为 499(client closed request),供访问日志归类。
|
||||
func failoverClientGone(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil || c.Request.Context().Err() == nil {
|
||||
return false
|
||||
}
|
||||
// 先停 compact 心跳(接管 ResponseWriter,建立 happens-before),与
|
||||
// handleStreamingAwareError/errorResponse 等终结路径对齐,避免心跳
|
||||
// goroutine 与下面的状态标记并发触碰同一 writer。心跳已提交 200 时
|
||||
// 状态码已固化,不再标 499。
|
||||
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
|
||||
return true
|
||||
}
|
||||
if !c.Writer.Written() {
|
||||
c.Status(statusClientClosedRequest)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sleepWithContext 等待指定时长,返回 false 表示 context 已取消。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
|
||||
@@ -2,11 +2,14 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -128,12 +131,56 @@ func TestSleepWithContext(t *testing.T) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHandleFailoverError_BasicSwitch(t *testing.T) {
|
||||
t.Run("显式停止不切换账号且旧错误默认仍切换", func(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
stopErr := &service.UpstreamFailoverError{
|
||||
Stage: service.GatewayFailureStageAccountAuth,
|
||||
Scope: service.GatewayFailureScopeProvider,
|
||||
NextAccountAction: service.NextAccountStop,
|
||||
}
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformGrok, maxSameAccountRetries, stopErr)
|
||||
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
require.Zero(t, fs.SwitchCount)
|
||||
require.Empty(t, fs.FailedAccountIDs)
|
||||
require.Equal(t, stopErr, fs.LastFailoverErr)
|
||||
|
||||
legacyErr := newTestFailoverErr(http.StatusTooManyRequests, false, false)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformGrok, maxSameAccountRetries, legacyErr)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SwitchCount)
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
})
|
||||
|
||||
t.Run("已取消的认证失败不改变切换状态", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
err := &service.UpstreamFailoverError{
|
||||
Stage: service.GatewayFailureStageAccountAuth,
|
||||
Scope: service.GatewayFailureScopeAccount,
|
||||
NextAccountAction: service.NextAccountRetry,
|
||||
}
|
||||
|
||||
action := fs.HandleFailoverError(ctx, mock, 101, service.PlatformGrok, maxSameAccountRetries, err)
|
||||
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
require.Zero(t, fs.SwitchCount)
|
||||
require.Empty(t, fs.FailedAccountIDs)
|
||||
require.Nil(t, fs.LastFailoverErr)
|
||||
require.Empty(t, mock.calls)
|
||||
})
|
||||
|
||||
t.Run("非重试错误_非Antigravity_直接切换", func(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SwitchCount)
|
||||
@@ -150,7 +197,7 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
@@ -166,7 +213,7 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
|
||||
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
@@ -181,19 +228,19 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
|
||||
|
||||
// 第一次切换:0→1
|
||||
err1 := newTestFailoverErr(500, false, false)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err1)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SwitchCount)
|
||||
|
||||
// 第二次切换:1→2
|
||||
err2 := newTestFailoverErr(502, false, false)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, err2)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 2, fs.SwitchCount)
|
||||
|
||||
// 第三次已耗尽:SwitchCount(2) >= MaxSwitches(2)
|
||||
err3 := newTestFailoverErr(503, false, false)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", err3)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", maxSameAccountRetries, err3)
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
require.Equal(t, 2, fs.SwitchCount, "耗尽时不应继续递增")
|
||||
|
||||
@@ -212,7 +259,7 @@ func TestHandleFailoverError_BasicSwitch(t *testing.T) {
|
||||
fs := NewFailoverState(0, false)
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
require.Equal(t, 0, fs.SwitchCount)
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
@@ -229,7 +276,7 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
|
||||
fs := NewFailoverState(3, true) // hasBoundSession=true
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.True(t, fs.ForceCacheBilling)
|
||||
})
|
||||
|
||||
@@ -238,7 +285,7 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, false, true) // ForceCacheBilling=true
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.True(t, fs.ForceCacheBilling)
|
||||
})
|
||||
|
||||
@@ -247,7 +294,7 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.False(t, fs.ForceCacheBilling)
|
||||
})
|
||||
|
||||
@@ -257,12 +304,12 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
|
||||
|
||||
// 第一次:ForceCacheBilling=true → 设置
|
||||
err1 := newTestFailoverErr(500, false, true)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err1)
|
||||
require.True(t, fs.ForceCacheBilling)
|
||||
|
||||
// 第二次:ForceCacheBilling=false → 仍然保持 true
|
||||
err2 := newTestFailoverErr(502, false, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, err2)
|
||||
require.True(t, fs.ForceCacheBilling, "ForceCacheBilling 一旦设置不应被重置")
|
||||
})
|
||||
}
|
||||
@@ -278,7 +325,7 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) {
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
@@ -297,7 +344,7 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) {
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
|
||||
for i := 1; i <= maxSameAccountRetries; i++ {
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, i, fs.SameAccountRetryCount[100])
|
||||
}
|
||||
@@ -311,12 +358,12 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) {
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
|
||||
for i := 0; i < maxSameAccountRetries; i++ {
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
}
|
||||
require.Equal(t, maxSameAccountRetries, fs.SameAccountRetryCount[100])
|
||||
|
||||
// 第 maxSameAccountRetries+1 次:重试耗尽,应切换账号
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SwitchCount)
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
@@ -333,12 +380,12 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) {
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
|
||||
// 账号 100 第一次重试
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100])
|
||||
|
||||
// 账号 200 第一次重试(独立计数)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", err)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[200])
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100], "账号 100 的计数不应受影响")
|
||||
@@ -351,17 +398,54 @@ func TestHandleFailoverError_SameAccountRetry(t *testing.T) {
|
||||
|
||||
// 耗尽账号 100 的重试
|
||||
for i := 0; i < maxSameAccountRetries; i++ {
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
}
|
||||
// 第 maxSameAccountRetries+1 次: 重试耗尽 → 切换
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
|
||||
// 再次遇到账号 100,计数仍为 maxSameAccountRetries,条件不满足 → 直接切换
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Len(t, mock.calls, 2, "第二次耗尽也应调用 TempUnschedule")
|
||||
})
|
||||
|
||||
t.Run("尊重账号级retryLimit_配置1次只重试1次", func(t *testing.T) {
|
||||
// 回归测试:Anthropic 等路径此前硬编码同账号重试 3 次,忽略账号
|
||||
// pool_mode_retry_count 配置。此处验证传入 retryLimit=1 时只重试 1 次即切换。
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(5, false)
|
||||
err := newTestFailoverErr(403, true, false)
|
||||
const retryLimit = 1
|
||||
|
||||
// 第 1 次:同账号重试
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryLimit, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100])
|
||||
require.Equal(t, 0, fs.SwitchCount, "首次重试不应切换账号")
|
||||
require.Empty(t, mock.calls, "未耗尽前不应 TempUnschedule")
|
||||
|
||||
// 第 2 次:已达上限 1 → 不再同账号重试,直接切换 + TempUnschedule
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryLimit, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100], "重试计数不应超过 retryLimit")
|
||||
require.Equal(t, 1, fs.SwitchCount, "重试耗尽应切换账号")
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
require.Len(t, mock.calls, 1, "重试耗尽应触发 TempUnschedule")
|
||||
})
|
||||
|
||||
t.Run("retryLimit为0时立即切换不重试", func(t *testing.T) {
|
||||
// pool_mode_retry_count=0 表示关闭同账号重试(如 GPT Image 账号)。
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(5, false)
|
||||
err := newTestFailoverErr(403, true, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", 0, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 0, fs.SameAccountRetryCount[100], "retryLimit=0 不应发生同账号重试")
|
||||
require.Equal(t, 1, fs.SwitchCount, "应立即切换账号")
|
||||
require.Len(t, mock.calls, 1, "应立即 TempUnschedule")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -374,7 +458,7 @@ func TestHandleFailoverError_TempUnschedule(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, false, false) // RetryableOnSameAccount=false
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Empty(t, mock.calls)
|
||||
})
|
||||
|
||||
@@ -384,10 +468,10 @@ func TestHandleFailoverError_TempUnschedule(t *testing.T) {
|
||||
err := newTestFailoverErr(502, true, false)
|
||||
|
||||
for i := 0; i < maxSameAccountRetries; i++ {
|
||||
fs.HandleFailoverError(context.Background(), mock, 42, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 42, "openai", maxSameAccountRetries, err)
|
||||
}
|
||||
// 再次触发时才会执行 TempUnschedule + 切换
|
||||
fs.HandleFailoverError(context.Background(), mock, 42, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 42, "openai", maxSameAccountRetries, err)
|
||||
|
||||
require.Len(t, mock.calls, 1)
|
||||
require.Equal(t, int64(42), mock.calls[0].accountID)
|
||||
@@ -407,16 +491,41 @@ func TestHandleFailoverError_ContextCanceled(t *testing.T) {
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
cancel() // 通过入口检查后、sleep 期间取消
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(ctx, mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(ctx, mock, 100, "openai", maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
require.Less(t, elapsed, 400*time.Millisecond, "sleep 应被取消打断")
|
||||
// 进入重试分支后才取消:重试计数已递增
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100])
|
||||
})
|
||||
|
||||
t.Run("入口即已取消_不改动任何failover状态", func(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(520, false, false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 调用前客户端已断开
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(ctx, mock, 100, "openai", maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
require.Less(t, elapsed, 100*time.Millisecond, "应立即返回")
|
||||
// 重试计数仍应递增
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[100])
|
||||
// 入口已取消时不得改变任何 failover 状态。
|
||||
require.Equal(t, 0, fs.SwitchCount, "取消的请求不应计入切换")
|
||||
require.Equal(t, 0, fs.SameAccountRetryCount[100], "取消的请求不应改动重试计数")
|
||||
require.NotContains(t, fs.FailedAccountIDs, int64(100))
|
||||
require.Nil(t, fs.LastFailoverErr)
|
||||
require.Empty(t, mock.calls, "不应触发 TempUnschedule")
|
||||
})
|
||||
|
||||
t.Run("Antigravity延迟期间context取消", func(t *testing.T) {
|
||||
@@ -429,7 +538,7 @@ func TestHandleFailoverError_ContextCanceled(t *testing.T) {
|
||||
cancel() // 立即取消
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(ctx, mock, 100, service.PlatformAntigravity, err)
|
||||
action := fs.HandleFailoverError(ctx, mock, 100, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
@@ -446,10 +555,10 @@ func TestHandleFailoverError_FailedAccountIDs(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false))
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, newTestFailoverErr(500, false, false))
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", newTestFailoverErr(502, false, false))
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, newTestFailoverErr(502, false, false))
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(200))
|
||||
require.Len(t, fs.FailedAccountIDs, 2)
|
||||
})
|
||||
@@ -458,7 +567,7 @@ func TestHandleFailoverError_FailedAccountIDs(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(0, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false))
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, newTestFailoverErr(500, false, false))
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(100))
|
||||
})
|
||||
@@ -467,7 +576,7 @@ func TestHandleFailoverError_FailedAccountIDs(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(3, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(400, true, false))
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, newTestFailoverErr(400, true, false))
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.NotContains(t, fs.FailedAccountIDs, int64(100))
|
||||
})
|
||||
@@ -476,8 +585,8 @@ func TestHandleFailoverError_FailedAccountIDs(t *testing.T) {
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(5, false)
|
||||
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false))
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", newTestFailoverErr(500, false, false))
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, newTestFailoverErr(500, false, false))
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, newTestFailoverErr(500, false, false))
|
||||
require.Len(t, fs.FailedAccountIDs, 1, "map 天然去重")
|
||||
})
|
||||
}
|
||||
@@ -492,11 +601,11 @@ func TestHandleFailoverError_LastFailoverErr(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
|
||||
err1 := newTestFailoverErr(500, false, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err1)
|
||||
require.Equal(t, err1, fs.LastFailoverErr)
|
||||
|
||||
err2 := newTestFailoverErr(502, false, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, err2)
|
||||
require.Equal(t, err2, fs.LastFailoverErr)
|
||||
})
|
||||
|
||||
@@ -505,7 +614,7 @@ func TestHandleFailoverError_LastFailoverErr(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
|
||||
err := newTestFailoverErr(400, true, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, err, fs.LastFailoverErr)
|
||||
})
|
||||
}
|
||||
@@ -522,30 +631,30 @@ func TestHandleFailoverError_IntegrationScenario(t *testing.T) {
|
||||
// 1. 账号 100 遇到可重试错误,同账号重试 maxSameAccountRetries 次
|
||||
retryErr := newTestFailoverErr(400, true, false)
|
||||
for i := 0; i < maxSameAccountRetries; i++ {
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryErr)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
}
|
||||
require.True(t, fs.ForceCacheBilling, "hasBoundSession=true 应设置 ForceCacheBilling")
|
||||
|
||||
// 2. 账号 100 超过重试上限 → TempUnschedule + 切换
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", retryErr)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, retryErr)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SwitchCount)
|
||||
require.Len(t, mock.calls, 1)
|
||||
|
||||
// 3. 账号 200 遇到不可重试错误 → 直接切换
|
||||
switchErr := newTestFailoverErr(500, false, false)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", switchErr)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, switchErr)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 2, fs.SwitchCount)
|
||||
|
||||
// 4. 账号 300 遇到不可重试错误 → 再切换
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", switchErr)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, "openai", maxSameAccountRetries, switchErr)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 3, fs.SwitchCount)
|
||||
|
||||
// 5. 账号 400 → 已耗尽 (SwitchCount=3 >= MaxSwitches=3)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 400, "openai", switchErr)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 400, "openai", maxSameAccountRetries, switchErr)
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
|
||||
// 最终状态验证
|
||||
@@ -563,21 +672,21 @@ func TestHandleFailoverError_IntegrationScenario(t *testing.T) {
|
||||
|
||||
// 第一次切换:delay = 0s
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Less(t, elapsed, 200*time.Millisecond, "第一次切换延迟为 0")
|
||||
|
||||
// 第二次切换:delay = 1s
|
||||
start = time.Now()
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, err)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 200, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed = time.Since(start)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.GreaterOrEqual(t, elapsed, 800*time.Millisecond, "第二次切换延迟约 1s")
|
||||
|
||||
// 第三次:耗尽(无延迟,因为在检查延迟之前就返回了)
|
||||
start = time.Now()
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, service.PlatformAntigravity, err)
|
||||
action = fs.HandleFailoverError(context.Background(), mock, 300, service.PlatformAntigravity, maxSameAccountRetries, err)
|
||||
elapsed = time.Since(start)
|
||||
require.Equal(t, FailoverExhausted, action)
|
||||
require.Less(t, elapsed, 200*time.Millisecond, "耗尽时不应有延迟")
|
||||
@@ -589,17 +698,17 @@ func TestHandleFailoverError_IntegrationScenario(t *testing.T) {
|
||||
|
||||
// 第一次:ForceCacheBilling=false
|
||||
err1 := newTestFailoverErr(500, false, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", err1)
|
||||
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err1)
|
||||
require.False(t, fs.ForceCacheBilling)
|
||||
|
||||
// 第二次:ForceCacheBilling=true(Antigravity 粘性会话切换)
|
||||
err2 := newTestFailoverErr(500, false, true)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", err2)
|
||||
fs.HandleFailoverError(context.Background(), mock, 200, "openai", maxSameAccountRetries, err2)
|
||||
require.True(t, fs.ForceCacheBilling, "错误标志应触发 ForceCacheBilling")
|
||||
|
||||
// 第三次:ForceCacheBilling=false,但状态仍保持 true
|
||||
err3 := newTestFailoverErr(500, false, false)
|
||||
fs.HandleFailoverError(context.Background(), mock, 300, "openai", err3)
|
||||
fs.HandleFailoverError(context.Background(), mock, 300, "openai", maxSameAccountRetries, err3)
|
||||
require.True(t, fs.ForceCacheBilling, "不应重置")
|
||||
})
|
||||
}
|
||||
@@ -614,7 +723,7 @@ func TestHandleFailoverError_EdgeCases(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(0, false, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
})
|
||||
|
||||
@@ -623,7 +732,7 @@ func TestHandleFailoverError_EdgeCases(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, true, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 0, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 0, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[0])
|
||||
})
|
||||
@@ -633,7 +742,7 @@ func TestHandleFailoverError_EdgeCases(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
err := newTestFailoverErr(500, true, false)
|
||||
|
||||
action := fs.HandleFailoverError(context.Background(), mock, -1, "openai", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, -1, "openai", maxSameAccountRetries, err)
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
require.Equal(t, 1, fs.SameAccountRetryCount[-1])
|
||||
})
|
||||
@@ -645,7 +754,7 @@ func TestHandleFailoverError_EdgeCases(t *testing.T) {
|
||||
err := newTestFailoverErr(500, false, false)
|
||||
|
||||
start := time.Now()
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "", err)
|
||||
action := fs.HandleFailoverError(context.Background(), mock, 100, "", maxSameAccountRetries, err)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
@@ -718,6 +827,29 @@ func TestHandleSelectionExhausted(t *testing.T) {
|
||||
require.Less(t, elapsed, 100*time.Millisecond, "应立即返回")
|
||||
})
|
||||
|
||||
t.Run("context已取消_非503也返回Canceled而非Exhausted", func(t *testing.T) {
|
||||
// #4257 核心场景:客户端断开后选号失败源于 context canceled,
|
||||
// 不应被当成账号耗尽转成 502。
|
||||
fs := NewFailoverState(3, false)
|
||||
fs.LastFailoverErr = newTestFailoverErr(520, false, false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
action := fs.HandleSelectionExhausted(ctx)
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
})
|
||||
|
||||
t.Run("context已取消_无LastFailoverErr也返回Canceled", func(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
action := fs.HandleSelectionExhausted(ctx)
|
||||
require.Equal(t, FailoverCanceled, action)
|
||||
})
|
||||
|
||||
t.Run("503且SwitchCount等于MaxSwitches_仍可重试", func(t *testing.T) {
|
||||
fs := NewFailoverState(2, false)
|
||||
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
|
||||
@@ -727,3 +859,47 @@ func TestHandleSelectionExhausted(t *testing.T) {
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// failoverClientGone 测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFailoverClientGone(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("活跃请求返回false", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
|
||||
require.False(t, failoverClientGone(c))
|
||||
require.Equal(t, http.StatusOK, c.Writer.Status(), "不应改动状态码")
|
||||
})
|
||||
|
||||
t.Run("客户端已断开_返回true并标记499", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
|
||||
|
||||
require.True(t, failoverClientGone(c))
|
||||
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
|
||||
})
|
||||
|
||||
t.Run("响应已提交_不改状态码", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx)
|
||||
c.String(http.StatusOK, "partial")
|
||||
|
||||
require.True(t, failoverClientGone(c))
|
||||
require.Equal(t, http.StatusOK, c.Writer.Status(), "已提交的状态码不应被覆盖")
|
||||
})
|
||||
|
||||
t.Run("nil安全", func(t *testing.T) {
|
||||
require.False(t, failoverClientGone(nil))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64
|
||||
// GatewayHandler handles API gateway requests
|
||||
type GatewayHandler struct {
|
||||
gatewayService *service.GatewayService
|
||||
openAIGatewayService *service.OpenAIGatewayService
|
||||
geminiCompatService *service.GeminiMessagesCompatService
|
||||
antigravityGatewayService *service.AntigravityGatewayService
|
||||
userService *service.UserService
|
||||
@@ -59,6 +60,7 @@ type GatewayHandler struct {
|
||||
// NewGatewayHandler creates a new GatewayHandler
|
||||
func NewGatewayHandler(
|
||||
gatewayService *service.GatewayService,
|
||||
openAIGatewayService *service.OpenAIGatewayService,
|
||||
geminiCompatService *service.GeminiMessagesCompatService,
|
||||
antigravityGatewayService *service.AntigravityGatewayService,
|
||||
userService *service.UserService,
|
||||
@@ -94,6 +96,7 @@ func NewGatewayHandler(
|
||||
|
||||
return &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
openAIGatewayService: openAIGatewayService,
|
||||
geminiCompatService: geminiCompatService,
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
userService: userService,
|
||||
@@ -327,6 +330,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
continue
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
default: // FailoverExhausted
|
||||
if fs.LastFailoverErr != nil {
|
||||
@@ -448,7 +452,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, failoverErr, service.PlatformGemini, true)
|
||||
return
|
||||
}
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
||||
switch action {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
@@ -456,6 +460,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformGemini, streamStarted)
|
||||
return
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -613,6 +618,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
continue
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
default: // FailoverExhausted
|
||||
if fs.LastFailoverErr != nil {
|
||||
@@ -868,7 +874,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, failoverErr, account.Platform, true)
|
||||
return
|
||||
}
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
||||
switch action {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
@@ -876,6 +882,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
|
||||
return
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1454,13 +1461,14 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
|
||||
remaining := h.calculateSubscriptionRemaining(apiKey.Group, subscription)
|
||||
resp["remaining"] = remaining
|
||||
resp["subscription"] = gin.H{
|
||||
"daily_usage_usd": subscription.DailyUsageUSD,
|
||||
"weekly_usage_usd": subscription.WeeklyUsageUSD,
|
||||
"monthly_usage_usd": subscription.MonthlyUsageUSD,
|
||||
"daily_limit_usd": apiKey.Group.DailyLimitUSD,
|
||||
"weekly_limit_usd": apiKey.Group.WeeklyLimitUSD,
|
||||
"monthly_limit_usd": apiKey.Group.MonthlyLimitUSD,
|
||||
"expires_at": subscription.ExpiresAt,
|
||||
"daily_usage_usd": subscription.DailyUsageUSD,
|
||||
"weekly_usage_usd": subscription.WeeklyUsageUSD,
|
||||
"monthly_usage_usd": subscription.MonthlyUsageUSD,
|
||||
"daily_limit_usd": apiKey.Group.DailyLimitUSD,
|
||||
"weekly_limit_usd": apiKey.Group.WeeklyLimitUSD,
|
||||
"monthly_limit_usd": apiKey.Group.MonthlyLimitUSD,
|
||||
"weekly_window_start": subscription.WeeklyWindowStart,
|
||||
"expires_at": subscription.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type countingGatewaySchedulerCache struct {
|
||||
*fakeSchedulerCache
|
||||
snapshotCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingGatewaySchedulerCache) GetSnapshot(ctx context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) {
|
||||
c.snapshotCalls.Add(1)
|
||||
return c.fakeSchedulerCache.GetSnapshot(ctx, bucket)
|
||||
}
|
||||
|
||||
func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(9100)
|
||||
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformAnthropic, Status: service.StatusActive}
|
||||
account := &service.Account{
|
||||
ID: 9101, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive, Schedulable: true, Concurrency: 1,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 9101, GroupID: groupID}},
|
||||
}
|
||||
schedulerCache := &countingGatewaySchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{account}}}
|
||||
schedulerSnapshot := service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil)
|
||||
gatewayService := service.NewGatewayService(
|
||||
nil, &fakeGroupRepo{group: group}, nil, nil, nil, nil, nil, nil, nil,
|
||||
schedulerSnapshot, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billingCacheService.Stop)
|
||||
h := &GatewayHandler{
|
||||
gatewayService: gatewayService,
|
||||
billingCacheService: billingCacheService,
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0),
|
||||
maxAccountSwitches: 1,
|
||||
cfg: cfg,
|
||||
}
|
||||
apiKey := &service.APIKey{
|
||||
ID: 9102, UserID: 9103, GroupID: &groupID, Group: group, Status: service.StatusActive,
|
||||
User: &service.User{ID: 9103, Concurrency: 10, Balance: 100},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
call func(*gin.Context)
|
||||
}{
|
||||
{
|
||||
name: "responses", path: "/v1/responses", body: `{"model":"claude-test","input":"hello","stream":false}`,
|
||||
call: h.Responses,
|
||||
},
|
||||
{
|
||||
name: "chat completions", path: "/v1/chat/completions", body: `{"model":"claude-test","messages":[{"role":"user","content":"hello"}],"stream":false}`,
|
||||
call: h.ChatCompletions,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
schedulerCache.snapshotCalls.Store(0)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
ctx = context.WithValue(ctx, ctxkey.Group, group)
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBufferString(tt.body)).WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.Request = req
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
|
||||
|
||||
tt.call(c)
|
||||
|
||||
require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector")
|
||||
_, selected := c.Get(opsAccountIDKey)
|
||||
require.False(t, selected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
|
||||
return
|
||||
}
|
||||
if service.IsGPTImageGenerationModel(reqModel) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "This model is not supported on the Chat Completions endpoint")
|
||||
return
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
@@ -159,6 +163,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
|
||||
for {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
@@ -178,6 +185,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
default:
|
||||
if fs.LastFailoverErr != nil {
|
||||
@@ -254,7 +262,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.handleCCFailoverExhausted(c, failoverErr, true)
|
||||
return
|
||||
}
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
||||
switch action {
|
||||
case FailoverContinue:
|
||||
continue
|
||||
@@ -262,6 +270,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.handleCCFailoverExhausted(c, fs.LastFailoverErr, streamStarted)
|
||||
return
|
||||
case FailoverCanceled:
|
||||
failoverClientGone(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -328,6 +337,14 @@ func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *serv
|
||||
if streamStarted {
|
||||
return
|
||||
}
|
||||
if lastErr != nil {
|
||||
copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
|
||||
}
|
||||
if lastErr != nil && lastErr.IsCredentialFailure() {
|
||||
status, message := credentialFailoverClientResponse(lastErr)
|
||||
h.chatCompletionsErrorResponse(c, status, "server_error", message)
|
||||
return
|
||||
}
|
||||
statusCode := http.StatusBadGateway
|
||||
if lastErr != nil && lastErr.StatusCode > 0 {
|
||||
statusCode = lastErr.StatusCode
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user