Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0fbcc87d | ||
|
|
df99b9417c | ||
|
|
819fda34d9 | ||
|
|
f93a6c50ce | ||
|
|
a8cfafa02c | ||
|
|
d57106a56f | ||
|
|
2fc4fef847 | ||
|
|
fcd3bc1272 | ||
|
|
44f502bab8 | ||
|
|
ac6e36f96b | ||
|
|
40c8252734 | ||
|
|
5f022663ac | ||
|
|
8a7269f539 | ||
|
|
683a8d8096 | ||
|
|
54ef446c1d | ||
|
|
720db8983f | ||
|
|
2a80495880 | ||
|
|
8a87a658ad | ||
|
|
939905b8c7 | ||
|
|
0d28642181 | ||
|
|
1b9645ca32 | ||
|
|
e7a4f3f465 | ||
|
|
f29ccc7dfb | ||
|
|
b2e2c7e69c | ||
|
|
b3a07aeae7 | ||
|
|
39be1ec97f | ||
|
|
fd9c945fab | ||
|
|
94c059c99e | ||
|
|
1721b2b2d1 | ||
|
|
32ddd5c60b | ||
|
|
d53dd112ce | ||
|
|
36a352f5eb | ||
|
|
9983f5b80a | ||
|
|
0bff6f9ac5 | ||
|
|
6db9e42352 | ||
|
|
fd0da2570d | ||
|
|
50c3e52799 | ||
|
|
db1813f7a3 | ||
|
|
6d50934de6 | ||
|
|
27278f62ed | ||
|
|
d65b4c90ad | ||
|
|
e89f4b43c0 | ||
|
|
38355ed204 | ||
|
|
03f6a28096 | ||
|
|
c9f42e1f77 | ||
|
|
29122e3051 | ||
|
|
65ad7df4f4 | ||
|
|
cc7612bdbd | ||
|
|
ce6af41357 | ||
|
|
0112782049 | ||
|
|
650c50e34b | ||
|
|
55242ffac1 | ||
|
|
2b49d662cc | ||
|
|
0a97a5f461 | ||
|
|
00d68ff6df | ||
|
|
dbdbfb1122 | ||
|
|
4567f6582b | ||
|
|
063454ae96 | ||
|
|
a1560ae77c | ||
|
|
30adee43bc | ||
|
|
65fa728921 | ||
|
|
82576e0a38 | ||
|
|
9707dedca2 | ||
|
|
ae5e980dd1 | ||
|
|
28e7adef09 | ||
|
|
9491de0a39 | ||
|
|
147c1879d9 | ||
|
|
32df33a1c3 | ||
|
|
85a3b12254 | ||
|
|
c6f375d3ab | ||
|
|
9f5b57fc96 | ||
|
|
e5f38a6f60 | ||
|
|
d430343f51 | ||
|
|
6936687870 | ||
|
|
f4b51b0f2b | ||
|
|
8105846f33 | ||
|
|
f079742bab | ||
|
|
a560d2f91e | ||
|
|
d2ff9c0c4c | ||
|
|
92ce5bfe24 | ||
|
|
febfe538b7 | ||
|
|
6e27b82335 | ||
|
|
f6d734ec23 | ||
|
|
af1a032d35 | ||
|
|
93de19665b | ||
|
|
bed12b7016 | ||
|
|
f597e926da | ||
|
|
7b5fbe5197 | ||
|
|
04494fd0d9 | ||
|
|
945b9b208b | ||
|
|
b0d5592ae2 | ||
|
|
5cb8cdd36c | ||
|
|
6cfb7898df | ||
|
|
efffd5d791 | ||
|
|
40e1cc14b3 | ||
|
|
ecedc7c8d3 | ||
|
|
0fa604ba71 | ||
|
|
d3dfa28f8e | ||
|
|
2dc1387b59 | ||
|
|
510adf703c | ||
|
|
e3e31bd4c5 | ||
|
|
89cfe24a0c | ||
|
|
51d722906c | ||
|
|
952be8711b | ||
|
|
bab8a9a93e | ||
|
|
31640363fc | ||
|
|
8c4a43cf72 | ||
|
|
369f53a7a4 | ||
|
|
abc203a37b | ||
|
|
4a5665da5b | ||
|
|
6c2db4f461 |
@@ -33,7 +33,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -12,6 +12,9 @@ permissions:
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
cla-check:
|
||||
if: |
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
- name: Set up Node.js
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**AI API Gateway Platform for Subscription Quota Distribution**
|
||||
|
||||
English | [日本語](README_JA.md)
|
||||
English | [中文](README_CN.md) | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,22 +24,6 @@ Please read the following carefully before using this project:
|
||||
- **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited.
|
||||
- **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project.
|
||||
|
||||
## Overview
|
||||
|
||||
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
|
||||
- **API Key Distribution** - Generate and manage API Keys for users
|
||||
- **Precise Billing** - Token-level usage tracking and cost calculation
|
||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||
- **Rate Limiting** - Configurable request and token rate limits
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
||||
- **Admin Dashboard** - Web interface for monitoring and management
|
||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
> [Want to appear here?](mailto:support@pincc.ai)
|
||||
@@ -66,11 +50,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
<td>Thanks to SilkAPI for sponsoring this project! <a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
|
||||
<td>Thanks to YLS Code for sponsoring this project! <a href="https://ylscode.com/">YLS Code</a> is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
@@ -97,12 +76,6 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
|
||||
<td>Thanks to RunAPI for sponsoring this project! <a href="https://runapi.co/register?aff=fu2E">RunAPI</a> is an efficient and stable API platform and OpenRouter alternative. With one API Key, you can access 150+ popular models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with pricing as low as 10% of the original rate. It is highly stable and seamlessly compatible with tools such as Claude Code and OpenClaw.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
|
||||
@@ -121,8 +94,63 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.com">Register now</a> for a 1:1 top-up bonus — double your balance!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
|
||||
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
|
||||
Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
|
||||
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
|
||||
💰 Invoices supported
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
|
||||
- **API Key Distribution** - Generate and manage API Keys for users
|
||||
- **Precise Billing** - Token-level usage tracking and cost calculation
|
||||
- **Smart Scheduling** - Intelligent account selection with sticky sessions
|
||||
- **Concurrency Control** - Per-user and per-account concurrency limits
|
||||
- **Rate Limiting** - Configurable request and token rate limits
|
||||
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
|
||||
- **Admin Dashboard** - Web interface for monitoring and management
|
||||
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
|
||||
|
||||
## Ecosystem
|
||||
|
||||
Community projects that extend or integrate with Sub2API:
|
||||
@@ -559,6 +587,50 @@ Simple Mode is designed for individual developers or internal teams who want qui
|
||||
|
||||
---
|
||||
|
||||
## Grok / xAI OAuth Support
|
||||
|
||||
Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI.
|
||||
|
||||
### Supported Scope
|
||||
|
||||
- Platform name: `grok`
|
||||
- Account type: OAuth subscription accounts
|
||||
- Public gateway target: `/v1/responses` and `/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
|
||||
- Initial 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`
|
||||
- Out of scope for this provider: public Grok Chat Completions routes, image, video, TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
|
||||
### OAuth Configuration
|
||||
|
||||
The Grok OAuth flow uses PKCE and does not require committing private secrets. The default client details follow the public xAI OAuth flow used by compatible clients, and every value can be overridden by environment variable:
|
||||
|
||||
| Variable | Default |
|
||||
|----------|---------|
|
||||
| `XAI_OAUTH_CLIENT_ID` | Public xAI OAuth client ID |
|
||||
| `XAI_OAUTH_SCOPE` | `openid profile email offline_access grok-cli:access api:access` |
|
||||
| `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` |
|
||||
|
||||
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `POST /api/v1/admin/grok/oauth/auth-url` | Generate an xAI OAuth authorization URL |
|
||||
| `POST /api/v1/admin/grok/oauth/exchange-code` | Exchange a callback URL, query string, or code for OAuth credentials |
|
||||
| `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`.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Antigravity Support
|
||||
|
||||
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
# Sub2API
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://redis.io/)
|
||||
[](https://www.docker.com/)
|
||||
|
||||
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
**AI API 网关平台 - 订阅配额分发管理**
|
||||
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
## ⚠️ 重要提醒
|
||||
|
||||
使用本项目前,请务必仔细阅读以下内容:
|
||||
|
||||
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
|
||||
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
|
||||
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
|
||||
|
||||
## ❤️ 赞助商
|
||||
|
||||
> [想出现在这里?](mailto:support@pincc.ai)
|
||||
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openmodel.ai?ref=sub2api"><img src="assets/partners/logos/openmodel.jpg" alt="openmodel" width="150"></a></td>
|
||||
<td>一个API,顶级模型随便用!<a href="https://www.openmodel.ai?ref=sub2api">OpenModel</a> 专注于生产级、高可用的 AI API 网关,让你的应用真正做到高速稳定:自动故障转移、智能选最优渠道、生产级 SLA 保障。远超单一供应商的 SLA,让稳定性成为您的核心竞争力。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
|
||||
<td>感谢 丝绸API 赞助了本项目! <a href="https://code.silkapi.com/register?aff=SUB2API">丝绸API</a> 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
|
||||
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=1tsfr51"><img src="assets/partners/logos/pateway.png" alt="pateway" width="150"></a></td>
|
||||
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连的高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
|
||||
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
|
||||
现在通过 <a href="https://pateway.ai/?ch=1tsfr51">此链接</a> 注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
|
||||
<td>感谢 PPToken.org 赞助本项目! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.org/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
|
||||
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://roxybrowser.com/invite/bgGKG7"><img src="assets/partners/logos/RoxyBrowser.png" alt="RoxyBrowser" width="150"></a></td>
|
||||
<td>感谢 RoxyBrowser 赞助本项目!<a href="https://roxybrowser.com/invite/bgGKG7">RoxyBrowser</a> 是 Sub2API 的理想搭档:内置原生 Roxy AI Agent 与高质量原生住宅 IP,支持通过简单命令实现批量自动化,显著提升多账号管理的安全性与效率!点击<a href="https://roxybrowser.com/invite/bgGKG7">此链接</a>注册,可领取免费住宅 IP 套餐与终身 9 折优惠。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.com">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
|
||||
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
|
||||
独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
|
||||
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
|
||||
💰 支持开票
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 Sub2Api 的用户提供了专属福利:通过<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠 <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">>>For developers outside Mainland China, please click here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **多账号管理** - 支持多种上游账号类型(OAuth、API Key)
|
||||
- **API Key 分发** - 为用户生成和管理 API Key
|
||||
- **精确计费** - Token 级别的用量追踪和成本计算
|
||||
- **智能调度** - 智能账号选择,支持粘性会话
|
||||
- **并发控制** - 用户级和账号级并发限制
|
||||
- **速率限制** - 可配置的请求和 Token 速率限制
|
||||
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
|
||||
- **管理后台** - Web 界面进行监控和管理
|
||||
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
|
||||
|
||||
## 生态项目
|
||||
|
||||
围绕 Sub2API 的社区扩展与集成项目:
|
||||
|
||||
| 项目 | 说明 | 功能 |
|
||||
|------|------|------|
|
||||
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
|
||||
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 技术 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.25.7, Gin, Ent |
|
||||
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
|
||||
| 数据库 | PostgreSQL 15+ |
|
||||
| 缓存/队列 | Redis 7+ |
|
||||
|
||||
---
|
||||
|
||||
## Nginx 反向代理注意事项
|
||||
|
||||
通过 Nginx 反向代理 Sub2API(或 CRS 服务)并搭配 Codex CLI 使用时,需要在 Nginx 配置的 `http` 块中添加:
|
||||
|
||||
```nginx
|
||||
underscores_in_headers on;
|
||||
```
|
||||
|
||||
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
|
||||
|
||||
---
|
||||
|
||||
## 部署方式
|
||||
|
||||
### 方式一:脚本安装(推荐)
|
||||
|
||||
一键安装脚本,自动从 GitHub Releases 下载预编译的二进制文件。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Linux 服务器(amd64 或 arm64)
|
||||
- PostgreSQL 15+(已安装并运行)
|
||||
- Redis 7+(已安装并运行)
|
||||
- Root 权限
|
||||
|
||||
#### 安装步骤
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
1. 检测系统架构
|
||||
2. 下载最新版本
|
||||
3. 安装二进制文件到 `/opt/sub2api`
|
||||
4. 创建 systemd 服务
|
||||
5. 配置系统用户和权限
|
||||
|
||||
#### 安装后配置
|
||||
|
||||
```bash
|
||||
# 1. 启动服务
|
||||
sudo systemctl start sub2api
|
||||
|
||||
# 2. 设置开机自启
|
||||
sudo systemctl enable sub2api
|
||||
|
||||
# 3. 在浏览器中打开设置向导
|
||||
# http://你的服务器IP:8080
|
||||
```
|
||||
|
||||
设置向导将引导你完成:
|
||||
- 数据库配置
|
||||
- Redis 配置
|
||||
- 管理员账号创建
|
||||
|
||||
#### 升级
|
||||
|
||||
可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
|
||||
|
||||
网页升级功能支持:
|
||||
- 自动检测新版本
|
||||
- 一键下载并应用更新
|
||||
- 支持回滚
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 查看状态
|
||||
sudo systemctl status sub2api
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u sub2api -f
|
||||
|
||||
# 重启服务
|
||||
sudo systemctl restart sub2api
|
||||
|
||||
# 卸载
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式二:Docker Compose(推荐)
|
||||
|
||||
使用 Docker Compose 部署,包含 PostgreSQL 和 Redis 容器。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose v2+
|
||||
|
||||
#### 快速开始(一键部署)
|
||||
|
||||
使用自动化部署脚本快速搭建:
|
||||
|
||||
```bash
|
||||
# 创建部署目录
|
||||
mkdir -p sub2api-deploy && cd sub2api-deploy
|
||||
|
||||
# 下载并运行部署准备脚本
|
||||
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
|
||||
|
||||
# 启动服务
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f sub2api
|
||||
```
|
||||
|
||||
**脚本功能:**
|
||||
- 下载 `docker-compose.local.yml`(本地保存为 `docker-compose.yml`)和 `.env.example`
|
||||
- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)
|
||||
- 创建 `.env` 文件并填充自动生成的密钥
|
||||
- 创建数据目录(使用本地目录,便于备份和迁移)
|
||||
- 显示生成的凭证供你记录
|
||||
|
||||
#### 手动部署
|
||||
|
||||
如果你希望手动配置:
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api/deploy
|
||||
|
||||
# 2. 复制环境配置文件
|
||||
cp .env.example .env
|
||||
|
||||
# 3. 编辑配置(生成安全密码)
|
||||
nano .env
|
||||
```
|
||||
|
||||
**`.env` 必须配置项:**
|
||||
|
||||
```bash
|
||||
# PostgreSQL 密码(必需)
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
|
||||
# JWT 密钥(推荐 - 重启后保持用户登录状态)
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
|
||||
# TOTP 加密密钥(推荐 - 重启后保留双因素认证)
|
||||
TOTP_ENCRYPTION_KEY=your_totp_key_here
|
||||
|
||||
# 可选:管理员账号
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=your_admin_password
|
||||
|
||||
# 可选:自定义端口
|
||||
SERVER_PORT=8080
|
||||
```
|
||||
|
||||
**生成安全密钥:**
|
||||
```bash
|
||||
# 生成 JWT_SECRET
|
||||
openssl rand -hex 32
|
||||
|
||||
# 生成 TOTP_ENCRYPTION_KEY
|
||||
openssl rand -hex 32
|
||||
|
||||
# 生成 POSTGRES_PASSWORD
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
```bash
|
||||
# 4. 创建数据目录(本地版)
|
||||
mkdir -p data postgres_data redis_data
|
||||
|
||||
# 5. 启动所有服务
|
||||
# 选项 A:本地目录版(推荐 - 易于迁移)
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# 选项 B:命名卷版(简单设置)
|
||||
docker compose up -d
|
||||
|
||||
# 6. 查看状态
|
||||
docker compose -f docker-compose.local.yml ps
|
||||
|
||||
# 7. 查看日志
|
||||
docker compose -f docker-compose.local.yml logs -f sub2api
|
||||
```
|
||||
|
||||
#### 部署版本对比
|
||||
|
||||
| 版本 | 数据存储 | 迁移便利性 | 适用场景 |
|
||||
|------|---------|-----------|---------|
|
||||
| **docker-compose.local.yml** | 本地目录 | ✅ 简单(打包整个目录) | 生产环境、频繁备份 |
|
||||
| **docker-compose.yml** | 命名卷 | ⚠️ 需要 docker 命令 | 简单设置 |
|
||||
|
||||
**推荐:** 使用 `docker-compose.local.yml`(脚本部署)以便更轻松地管理数据。
|
||||
|
||||
#### 启用“数据管理”功能(datamanagementd)
|
||||
|
||||
如需启用管理后台“数据管理”,需要额外部署宿主机数据管理进程 `datamanagementd`。
|
||||
|
||||
关键点:
|
||||
|
||||
- 主进程固定探测:`/tmp/sub2api-datamanagement.sock`
|
||||
- 只有该 Socket 可连通时,数据管理功能才会开启
|
||||
- Docker 场景需将宿主机 Socket 挂载到容器同路径
|
||||
|
||||
详细部署步骤见:`deploy/DATAMANAGEMENTD_CN.md`
|
||||
|
||||
#### 访问
|
||||
|
||||
在浏览器中打开 `http://你的服务器IP:8080`
|
||||
|
||||
如果管理员密码是自动生成的,在日志中查找:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
|
||||
```
|
||||
|
||||
#### 升级
|
||||
|
||||
```bash
|
||||
# 拉取最新镜像并重建容器
|
||||
docker compose -f docker-compose.local.yml pull
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### 轻松迁移(本地目录版)
|
||||
|
||||
使用 `docker-compose.local.yml` 时,可以轻松迁移到新服务器:
|
||||
|
||||
```bash
|
||||
# 源服务器
|
||||
docker compose -f docker-compose.local.yml down
|
||||
cd ..
|
||||
tar czf sub2api-complete.tar.gz sub2api-deploy/
|
||||
|
||||
# 传输到新服务器
|
||||
scp sub2api-complete.tar.gz user@new-server:/path/
|
||||
|
||||
# 新服务器
|
||||
tar xzf sub2api-complete.tar.gz
|
||||
cd sub2api-deploy/
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
#### 常用命令
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker compose -f docker-compose.local.yml down
|
||||
|
||||
# 重启
|
||||
docker compose -f docker-compose.local.yml restart
|
||||
|
||||
# 查看所有日志
|
||||
docker compose -f docker-compose.local.yml logs -f
|
||||
|
||||
# 删除所有数据(谨慎!)
|
||||
docker compose -f docker-compose.local.yml down
|
||||
rm -rf data/ postgres_data/ redis_data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 方式三:源码编译
|
||||
|
||||
从源码编译安装,适合开发或定制需求。
|
||||
|
||||
#### 前置条件
|
||||
|
||||
- Go 1.21+
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
|
||||
#### 编译步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/Wei-Shaw/sub2api.git
|
||||
cd sub2api
|
||||
|
||||
# 2. 安装 pnpm(如果还没有安装)
|
||||
npm install -g pnpm
|
||||
|
||||
# 3. 编译前端
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm run build
|
||||
# 构建产物输出到 ../backend/internal/web/dist/
|
||||
|
||||
# 4. 编译后端(嵌入前端)
|
||||
cd ../backend
|
||||
go build -tags embed -o sub2api ./cmd/server
|
||||
|
||||
# 5. 创建配置文件
|
||||
cp ../deploy/config.example.yaml ./config.yaml
|
||||
|
||||
# 6. 编辑配置
|
||||
nano config.yaml
|
||||
```
|
||||
|
||||
> **注意:** `-tags embed` 参数会将前端嵌入到二进制文件中。不使用此参数编译的程序将不包含前端界面。
|
||||
|
||||
**`config.yaml` 关键配置:**
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
mode: "release"
|
||||
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
user: "postgres"
|
||||
password: "your_password"
|
||||
dbname: "sub2api"
|
||||
|
||||
redis:
|
||||
host: "localhost"
|
||||
port: 6379
|
||||
password: ""
|
||||
|
||||
jwt:
|
||||
secret: "change-this-to-a-secure-random-string"
|
||||
expire_hour: 24
|
||||
|
||||
default:
|
||||
user_concurrency: 5
|
||||
user_balance: 0
|
||||
api_key_prefix: "sk-"
|
||||
rate_multiplier: 1.0
|
||||
```
|
||||
|
||||
### Sora 功能状态(暂不可用)
|
||||
|
||||
> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。
|
||||
> 现阶段请勿在生产环境依赖 Sora 能力。
|
||||
> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。
|
||||
|
||||
### Sora 媒体签名 URL(功能恢复后可选)
|
||||
|
||||
当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
# /sora/media 是否强制要求 API Key(默认 false)
|
||||
sora_media_require_api_key: false
|
||||
# 媒体临时签名密钥(为空则禁用签名)
|
||||
sora_media_signing_key: "your-signing-key"
|
||||
# 临时签名 URL 有效期(秒)
|
||||
sora_media_signed_url_ttl_seconds: 900
|
||||
```
|
||||
|
||||
> 若未配置签名密钥,`/sora/media-signed` 将返回 503。
|
||||
> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。
|
||||
|
||||
访问策略说明:
|
||||
- `/sora/media`:内部调用或客户端携带 API Key 才能下载
|
||||
- `/sora/media-signed`:外部可访问,但有签名 + 过期控制
|
||||
|
||||
`config.yaml` 还支持以下安全相关配置:
|
||||
|
||||
- `cors.allowed_origins` 配置 CORS 白名单
|
||||
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
|
||||
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
|
||||
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
|
||||
- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
|
||||
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
|
||||
- `security.csp` 配置 Content-Security-Policy
|
||||
- `billing.circuit_breaker` 计费异常时 fail-closed
|
||||
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
|
||||
- `turnstile.required` 在 release 模式强制启用 Turnstile
|
||||
|
||||
**网关防御纵深建议(重点)**
|
||||
|
||||
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
|
||||
- `gateway.proxy_probe_response_read_max_bytes`:限制代理探测响应读取大小(默认 `1MB`)。
|
||||
- `gateway.gemini_debug_response_headers`:默认 `false`,仅在排障时短时开启,避免高频请求日志开销。
|
||||
- `/auth/register`、`/auth/login`、`/auth/login/2fa`、`/auth/send-verify-code` 已提供服务端兜底限流(Redis 故障时 fail-close)。
|
||||
- 推荐将 WAF/CDN 作为第一层防护,服务端限流与响应读取上限作为第二层兜底;两层同时保留,避免旁路流量与误配置风险。
|
||||
|
||||
**⚠️ 安全警告:HTTP URL 配置**
|
||||
|
||||
当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
url_allowlist:
|
||||
enabled: false # 禁用白名单检查
|
||||
allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全)
|
||||
```
|
||||
|
||||
**或通过环境变量:**
|
||||
|
||||
```bash
|
||||
SECURITY_URL_ALLOWLIST_ENABLED=false
|
||||
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
|
||||
```
|
||||
|
||||
**允许 HTTP 的风险:**
|
||||
- API 密钥和数据以**明文传输**(可被截获)
|
||||
- 易受**中间人攻击 (MITM)**
|
||||
- **不适合生产环境**
|
||||
|
||||
**适用场景:**
|
||||
- ✅ 开发/测试环境的本地服务器(http://localhost)
|
||||
- ✅ 内网可信端点
|
||||
- ✅ 获取 HTTPS 前测试账号连通性
|
||||
- ❌ 生产环境(仅使用 HTTPS)
|
||||
|
||||
**未设置此项时的错误示例:**
|
||||
```
|
||||
Invalid base URL: invalid url scheme: http
|
||||
```
|
||||
|
||||
如关闭 URL 校验或响应头过滤,请加强网络层防护:
|
||||
- 出站访问白名单限制上游域名/IP
|
||||
- 阻断私网/回环/链路本地地址
|
||||
- 强制仅允许 TLS 出站
|
||||
- 在反向代理层移除敏感响应头
|
||||
|
||||
```bash
|
||||
# 6. 运行应用
|
||||
./sub2api
|
||||
```
|
||||
|
||||
#### HTTP/2 (h2c) 与 HTTP/1.1 回退
|
||||
|
||||
后端明文端口默认支持 h2c,并保留 HTTP/1.1 回退用于 WebSocket 与旧客户端。浏览器通常不支持 h2c,性能收益主要在反向代理或内网链路。
|
||||
|
||||
**反向代理示例(Caddy):**
|
||||
|
||||
```caddyfile
|
||||
transport http {
|
||||
versions h2c h1
|
||||
}
|
||||
```
|
||||
|
||||
**验证:**
|
||||
|
||||
```bash
|
||||
# h2c prior knowledge
|
||||
curl --http2-prior-knowledge -I http://localhost:8080/health
|
||||
# HTTP/1.1 回退
|
||||
curl --http1.1 -I http://localhost:8080/health
|
||||
# WebSocket 回退验证(需管理员 token)
|
||||
websocat -H="Sec-WebSocket-Protocol: sub2api-admin, jwt.<ADMIN_TOKEN>" ws://localhost:8080/api/v1/admin/ops/ws/qps
|
||||
```
|
||||
|
||||
#### 开发模式
|
||||
|
||||
```bash
|
||||
# 后端(支持热重载)
|
||||
cd backend
|
||||
go run ./cmd/server
|
||||
|
||||
# 前端(支持热重载)
|
||||
cd frontend
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
#### 代码生成
|
||||
|
||||
修改 `backend/ent/schema` 后,需要重新生成 Ent + Wire:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go generate ./ent
|
||||
go generate ./cmd/server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 简易模式
|
||||
|
||||
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
|
||||
|
||||
- 启用方式:设置环境变量 `RUN_MODE=simple`
|
||||
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
|
||||
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
|
||||
|
||||
---
|
||||
|
||||
## Antigravity 使用说明
|
||||
|
||||
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
|
||||
|
||||
### 专用端点
|
||||
|
||||
| 端点 | 模型 |
|
||||
|------|------|
|
||||
| `/antigravity/v1/messages` | Claude 模型 |
|
||||
| `/antigravity/v1beta/` | Gemini 模型 |
|
||||
|
||||
### Claude Code 配置示例
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
|
||||
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
|
||||
```
|
||||
|
||||
### 混合调度模式
|
||||
|
||||
Antigravity 账户支持可选的**混合调度**功能。开启后,通用端点 `/v1/messages` 和 `/v1beta/` 也会调度该账户。
|
||||
|
||||
> **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。
|
||||
|
||||
|
||||
### 已知问题
|
||||
在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。)
|
||||
解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
sub2api/
|
||||
├── backend/ # Go 后端服务
|
||||
│ ├── cmd/server/ # 应用入口
|
||||
│ ├── internal/ # 内部模块
|
||||
│ │ ├── config/ # 配置管理
|
||||
│ │ ├── model/ # 数据模型
|
||||
│ │ ├── service/ # 业务逻辑
|
||||
│ │ ├── handler/ # HTTP 处理器
|
||||
│ │ └── gateway/ # API 网关核心
|
||||
│ └── resources/ # 静态资源
|
||||
│
|
||||
├── frontend/ # Vue 3 前端
|
||||
│ └── src/
|
||||
│ ├── api/ # API 调用
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── views/ # 页面组件
|
||||
│ └── components/ # 通用组件
|
||||
│
|
||||
└── deploy/ # 部署文件
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── .env.example # Docker Compose 环境变量
|
||||
├── config.example.yaml # 二进制部署完整配置文件
|
||||
└── install.sh # 一键安装脚本
|
||||
```
|
||||
|
||||
## 免责声明
|
||||
|
||||
> **使用本项目前请仔细阅读:**
|
||||
>
|
||||
> :rotating_light: **服务条款风险**: 使用本项目可能违反 Anthropic 的服务条款。请在使用前仔细阅读 Anthropic 的用户协议,使用本项目的一切风险由用户自行承担。
|
||||
>
|
||||
> :book: **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 [GNU 宽通用公共许可证 v3.0](LICENSE)(或更高版本)授权。
|
||||
|
||||
Copyright (c) 2026 Wesley Liddick
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果觉得有用,请给个 Star 支持一下!**
|
||||
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
|
||||
|
||||
[English](README.md) | 日本語
|
||||
[English](README.md) | [中文](README_CN.md) | 日本語
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,22 +24,6 @@
|
||||
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
|
||||
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
|
||||
|
||||
## 概要
|
||||
|
||||
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
|
||||
|
||||
## 機能
|
||||
|
||||
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
|
||||
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
|
||||
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
|
||||
## ❤️ スポンサー
|
||||
|
||||
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
|
||||
@@ -66,11 +50,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
<td>SilkAPI のご支援に感謝します!<a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
|
||||
<td>YLS Code のご支援に感謝します!<a href="https://ylscode.com/">YLS Code</a> は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
@@ -99,12 +78,6 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
|
||||
<td>RunAPI のご支援に感謝します!<a href="https://runapi.co/register?aff=fu2E">RunAPI</a> は効率的で安定した API プラットフォームで、OpenRouter の代替として利用できます。1つの API キーで OpenAI、Claude、Gemini、DeepSeek、Grok など 150以上の主要モデルにアクセスでき、価格は最低 10% から。非常に安定しており、Claude Code や OpenClaw などのツールとシームレスに互換します。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
|
||||
@@ -123,8 +96,59 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
|
||||
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.com">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
|
||||
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は、ByteDance がグローバル市場向けに独自開発した全モーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャに基づき、テキスト・画像・音声・動画の共同理解と生成をサポートします。エージェント協調をネイティブに有効化し、強力な推論・長期タスク実行・ツール統合・コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど多様なシナリオに広く適用可能です。マルチモーダル知覚、エンドツーエンドの複雑タスク配信、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて容易にアクセス・デプロイできます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">こちらのリンク</a>から登録すると、各モデルにつき 50 万トークンの無料推論クォータを獲得できます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陸地域の開発者はこちらをクリック</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
|
||||
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
|
||||
|
||||
## 機能
|
||||
|
||||
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
|
||||
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
|
||||
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
|
||||
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
|
||||
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
|
||||
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
|
||||
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
|
||||
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
|
||||
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
|
||||
|
||||
## エコシステム
|
||||
|
||||
Sub2API を拡張・統合するコミュニティプロジェクト:
|
||||
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1 +1 @@
|
||||
0.1.136
|
||||
0.1.138
|
||||
|
||||
@@ -94,6 +94,7 @@ func provideCleanup(
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
grokOAuth *service.GrokOAuthService,
|
||||
openAIGateway *service.OpenAIGatewayService,
|
||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||
backupSvc *service.BackupService,
|
||||
@@ -222,6 +223,12 @@ func provideCleanup(
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GrokOAuthService", func() error {
|
||||
if grokOAuth != nil {
|
||||
grokOAuth.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIWSPool", func() error {
|
||||
if openAIGateway != nil {
|
||||
openAIGateway.CloseOpenAIWSPool()
|
||||
|
||||
@@ -141,7 +141,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
privacyClientFactory := providePrivacyClientFactory()
|
||||
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
|
||||
grokOAuthClient := repository.NewGrokOAuthClient()
|
||||
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
|
||||
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
|
||||
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
|
||||
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
|
||||
driveClient := repository.NewGeminiDriveClient()
|
||||
@@ -178,8 +181,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
usageCache := service.NewUsageCache()
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
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)
|
||||
@@ -195,6 +199,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)
|
||||
proxyHandler := admin.NewProxyHandler(adminService)
|
||||
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||
promoHandler := admin.NewPromoHandler(promoService)
|
||||
@@ -242,7 +248,7 @@ 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, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
|
||||
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)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
@@ -266,7 +272,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
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, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
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)
|
||||
@@ -274,7 +280,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
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, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
Cleanup: v,
|
||||
@@ -325,6 +331,7 @@ func provideCleanup(
|
||||
openaiOAuth *service.OpenAIOAuthService,
|
||||
geminiOAuth *service.GeminiOAuthService,
|
||||
antigravityOAuth *service.AntigravityOAuthService,
|
||||
grokOAuth *service.GrokOAuthService,
|
||||
openAIGateway *service.OpenAIGatewayService,
|
||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||
backupSvc *service.BackupService,
|
||||
@@ -452,6 +459,12 @@ func provideCleanup(
|
||||
antigravityOAuth.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"GrokOAuthService", func() error {
|
||||
if grokOAuth != nil {
|
||||
grokOAuth.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"OpenAIWSPool", func() error {
|
||||
if openAIGateway != nil {
|
||||
openAIGateway.CloseOpenAIWSPool()
|
||||
|
||||
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
openAIOAuthSvc,
|
||||
geminiOAuthSvc,
|
||||
antigravityOAuthSvc,
|
||||
nil, // grokOAuth
|
||||
nil, // openAIGateway
|
||||
nil, // scheduledTestRunner
|
||||
nil, // backupSvc
|
||||
|
||||
@@ -41,7 +41,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity":
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
|
||||
@@ -164,6 +164,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=
|
||||
|
||||
@@ -643,6 +643,10 @@ type ProxyProbeConfig struct {
|
||||
|
||||
type BillingConfig struct {
|
||||
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
|
||||
// MinimumBalanceReserve is the conservative preflight floor for balance billing.
|
||||
// Requests in balance mode are rejected when the cached balance is below this
|
||||
// amount, even if it is still positive. Set to 0 to keep the legacy balance > 0 gate.
|
||||
MinimumBalanceReserve float64 `mapstructure:"minimum_balance_reserve"`
|
||||
// UserPlatformQuotaCacheTTLSeconds 用户 × 平台 quota 缓存 TTL(秒),默认 86400=1天,覆盖典型 daily 窗口。
|
||||
// 消费点:
|
||||
// - billing_cache_service.cacheWriteWorker 异步累加
|
||||
@@ -957,6 +961,9 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
|
||||
Queue float64 `mapstructure:"queue"`
|
||||
ErrorRate float64 `mapstructure:"error_rate"`
|
||||
TTFT float64 `mapstructure:"ttft"`
|
||||
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
|
||||
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
|
||||
Reset float64 `mapstructure:"reset"`
|
||||
}
|
||||
|
||||
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
|
||||
@@ -1055,6 +1062,11 @@ type GatewaySchedulingConfig struct {
|
||||
// 兜底层账户选择策略: "last_used"(按最后使用时间排序,默认) 或 "random"(随机)
|
||||
FallbackSelectionMode string `mapstructure:"fallback_selection_mode"`
|
||||
|
||||
// PreferSoonestReset 开启后,负载感知选择会优先选用「会话窗口最早重置」的账号
|
||||
// (use-it-or-lose-it:先用尽即将重置的账号,保留重置时间还很久的账号)。
|
||||
// 默认 false,保持原有「优先级 → 负载率 → LRU」行为不变。
|
||||
PreferSoonestReset bool `mapstructure:"prefer_soonest_reset"`
|
||||
|
||||
// 负载计算
|
||||
LoadBatchEnabled bool `mapstructure:"load_batch_enabled"`
|
||||
LoadBatchCacheTTLMS int `mapstructure:"load_batch_cache_ttl_ms"`
|
||||
@@ -1607,6 +1619,7 @@ func setDefaults() {
|
||||
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
|
||||
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
|
||||
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
|
||||
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
|
||||
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
|
||||
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
|
||||
|
||||
@@ -1870,6 +1883,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.queue", 0.7)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
|
||||
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.reset", 0.0)
|
||||
// OpenAI HTTP upstream protocol strategy
|
||||
viper.SetDefault("gateway.openai_http2.enabled", true)
|
||||
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
|
||||
@@ -1906,6 +1920,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
|
||||
viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100)
|
||||
viper.SetDefault("gateway.scheduling.fallback_selection_mode", "last_used")
|
||||
viper.SetDefault("gateway.scheduling.prefer_soonest_reset", false)
|
||||
viper.SetDefault("gateway.scheduling.load_batch_enabled", true)
|
||||
viper.SetDefault("gateway.scheduling.load_batch_cache_ttl_ms", 200)
|
||||
viper.SetDefault("gateway.scheduling.snapshot_mget_chunk_size", 128)
|
||||
@@ -2267,6 +2282,9 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
|
||||
}
|
||||
}
|
||||
if c.Billing.MinimumBalanceReserve < 0 {
|
||||
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
|
||||
}
|
||||
if c.Database.MaxOpenConns <= 0 {
|
||||
return fmt.Errorf("database.max_open_conns must be positive")
|
||||
}
|
||||
|
||||
@@ -1157,6 +1157,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Billing.CircuitBreaker.HalfOpenRequests = 0 },
|
||||
wantErr: "billing.circuit_breaker.half_open_requests",
|
||||
},
|
||||
{
|
||||
name: "billing minimum balance reserve",
|
||||
mutate: func(c *Config) { c.Billing.MinimumBalanceReserve = -0.01 },
|
||||
wantErr: "billing.minimum_balance_reserve",
|
||||
},
|
||||
{
|
||||
name: "database max open conns",
|
||||
mutate: func(c *Config) { c.Database.MaxOpenConns = 0 },
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
)
|
||||
|
||||
// Account type constants
|
||||
|
||||
@@ -774,16 +774,20 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
|
||||
return nil
|
||||
}
|
||||
protected := map[string]struct{}{
|
||||
"access_token": {},
|
||||
"refresh_token": {},
|
||||
"id_token": {},
|
||||
"expires_at": {},
|
||||
"email": {},
|
||||
"chatgpt_account_id": {},
|
||||
"chatgpt_user_id": {},
|
||||
"organization_id": {},
|
||||
"plan_type": {},
|
||||
"client_id": {},
|
||||
"access_token": {},
|
||||
"refresh_token": {},
|
||||
"id_token": {},
|
||||
"expires_at": {},
|
||||
"email": {},
|
||||
"chatgpt_account_id": {},
|
||||
"chatgpt_user_id": {},
|
||||
"organization_id": {},
|
||||
"plan_type": {},
|
||||
"client_id": {},
|
||||
"auth_mode": {},
|
||||
"openai_auth_mode": {},
|
||||
"token_type": {},
|
||||
"chatgpt_account_is_fedramp": {},
|
||||
}
|
||||
out := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
|
||||
@@ -849,6 +849,7 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
||||
} else if account.Platform == service.PlatformGemini {
|
||||
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
|
||||
@@ -509,6 +509,7 @@ var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformOpenAI: "openai",
|
||||
service.PlatformGemini: "google",
|
||||
service.PlatformAntigravity: "anthropic",
|
||||
service.PlatformGrok: "xai",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
type GrokOAuthHandler struct {
|
||||
grokOAuthService *service.GrokOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.GrokQuotaService
|
||||
}
|
||||
|
||||
func NewGrokOAuthHandler(
|
||||
grokOAuthService *service.GrokOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.GrokQuotaService,
|
||||
) *GrokOAuthHandler {
|
||||
return &GrokOAuthHandler{
|
||||
grokOAuthService: grokOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
}
|
||||
}
|
||||
|
||||
type GrokGenerateAuthURLRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
||||
var req GrokGenerateAuthURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req = GrokGenerateAuthURLRequest{}
|
||||
}
|
||||
result, err := h.grokOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.RedirectURI)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
type GrokExchangeCodeRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
State string `json:"state"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ExchangeCode(c *gin.Context) {
|
||||
var req GrokExchangeCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
type GrokRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RT string `json:"rt"`
|
||||
ClientID string `json:"client_id"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req GrokRefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
refreshToken := strings.TrimSpace(req.RefreshToken)
|
||||
if refreshToken == "" {
|
||||
refreshToken = strings.TrimSpace(req.RT)
|
||||
}
|
||||
if refreshToken == "" {
|
||||
response.BadRequest(c, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err == nil && proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, tokenInfo)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if account.Platform != service.PlatformGrok {
|
||||
response.BadRequest(c, "Account platform does not match Grok OAuth endpoint")
|
||||
return
|
||||
}
|
||||
if !account.IsOAuth() {
|
||||
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
newCredentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
newCredentials = service.MergeCredentials(account.Credentials, newCredentials)
|
||||
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
|
||||
newCredentials["base_url"] = baseURL
|
||||
}
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.AccountFromService(updatedAccount))
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
State string `json:"state"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Name string `json:"name"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
|
||||
SessionID: req.SessionID,
|
||||
Code: req.Code,
|
||||
State: req.State,
|
||||
RedirectURI: req.RedirectURI,
|
||||
ProxyID: req.ProxyID,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" && tokenInfo.Email != "" {
|
||||
name = tokenInfo.Email
|
||||
}
|
||||
if name == "" {
|
||||
name = "Grok OAuth Account"
|
||||
}
|
||||
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: name,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.quotaService == nil {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.ProbeUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.quotaService == nil {
|
||||
response.BadRequest(c, "grok quota service is not enabled")
|
||||
return
|
||||
}
|
||||
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *GrokOAuthHandler) RuntimeSanity(c *gin.Context) {
|
||||
response.Success(c, xai.RuntimeSanity())
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type grokQuotaHandlerAccountRepo struct {
|
||||
service.AccountRepository
|
||||
account *service.Account
|
||||
updates map[int64]map[string]any
|
||||
}
|
||||
|
||||
func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
|
||||
if r.account != nil && r.account.ID == id {
|
||||
return r.account, nil
|
||||
}
|
||||
return nil, service.ErrAccountNotFound
|
||||
}
|
||||
|
||||
func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||
if r.updates == nil {
|
||||
r.updates = make(map[int64]map[string]any)
|
||||
}
|
||||
r.updates[id] = updates
|
||||
return nil
|
||||
}
|
||||
|
||||
type grokQuotaHandlerUpstream struct {
|
||||
resp *http.Response
|
||||
lastReq *http.Request
|
||||
lastBody []byte
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
u.lastReq = req
|
||||
if req.Body != nil {
|
||||
u.lastBody, _ = io.ReadAll(req.Body)
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *grokQuotaHandlerUpstream) DoWithTLS(
|
||||
req *http.Request,
|
||||
proxyURL string,
|
||||
accountID int64,
|
||||
accountConcurrency int,
|
||||
_ *tlsfingerprint.Profile,
|
||||
) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
|
||||
ID: 42,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "access-token",
|
||||
"expires_at": time.Now().Add(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)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
|
||||
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(), `"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`)
|
||||
require.NotNil(t, repo.updates[42])
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
|
||||
ID: 43,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
}}
|
||||
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
|
||||
handler := NewGrokOAuthHandler(nil, nil, quotaService)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNotImplemented, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
|
||||
require.NotContains(t, rec.Body.String(), "access-token")
|
||||
}
|
||||
|
||||
func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
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)
|
||||
router := gin.New()
|
||||
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
|
||||
require.Contains(t, rec.Body.String(), `"valid":false`)
|
||||
require.NotContains(t, rec.Body.String(), "access_token")
|
||||
require.NotContains(t, rec.Body.String(), "secret")
|
||||
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -124,7 +124,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
@@ -106,6 +107,24 @@ type OpenAIRefreshTokenRequest struct {
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
}
|
||||
|
||||
type OpenAICodexPATCreateRequest struct {
|
||||
AccessToken string `json:"access_token" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
LoadFactor *int `json:"load_factor"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
CredentialExtras map[string]any `json:"credential_extras"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an OpenAI OAuth token
|
||||
// POST /api/v1/admin/openai/refresh-token
|
||||
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
|
||||
@@ -191,6 +210,7 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
|
||||
newCredentials[k] = v
|
||||
}
|
||||
}
|
||||
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
|
||||
|
||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Credentials: newCredentials,
|
||||
@@ -269,6 +289,114 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
|
||||
// POST /api/v1/admin/openai/create-from-codex-pat
|
||||
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
|
||||
var req OpenAICodexPATCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Concurrency != nil && *req.Concurrency < 0 {
|
||||
response.BadRequest(c, "concurrency must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.Priority != nil && *req.Priority < 0 {
|
||||
response.BadRequest(c, "priority must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
|
||||
response.BadRequest(c, "rate_multiplier must be >= 0")
|
||||
return
|
||||
}
|
||||
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
|
||||
response.BadRequest(c, "load_factor must be <= 10000")
|
||||
return
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if req.ProxyID != nil {
|
||||
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if proxy != nil {
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentials := mergeCodexImportMap(
|
||||
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
|
||||
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
|
||||
)
|
||||
extra := mergeCodexImportMap(req.Extra, map[string]any{
|
||||
"import_source": "codex_personal_access_token",
|
||||
"auth_provider": "codex_personal_access_token",
|
||||
"imported_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
|
||||
})
|
||||
|
||||
concurrency := 3
|
||||
if req.Concurrency != nil {
|
||||
concurrency = *req.Concurrency
|
||||
}
|
||||
priority := 50
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
skipDefaultGroupBind := false
|
||||
if req.SkipDefaultGroupBind != nil {
|
||||
skipDefaultGroupBind = *req.SkipDefaultGroupBind
|
||||
}
|
||||
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
|
||||
Notes: req.Notes,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: credentials,
|
||||
Extra: extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: concurrency,
|
||||
Priority: priority,
|
||||
RateMultiplier: req.RateMultiplier,
|
||||
LoadFactor: req.LoadFactor,
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
SkipDefaultGroupBind: skipDefaultGroupBind,
|
||||
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.AccountFromService(account))
|
||||
}
|
||||
|
||||
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
if tokenInfo != nil {
|
||||
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
|
||||
if candidate = strings.TrimSpace(candidate); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Codex PAT Account"
|
||||
}
|
||||
|
||||
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
|
||||
// GET /api/v1/admin/openai/accounts/:id/quota
|
||||
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -115,24 +116,105 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "fulfillment retried"})
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
|
||||
if len(orders) == 0 {
|
||||
return orders
|
||||
}
|
||||
out := make([]*dbent.PaymentOrder, 0, len(orders))
|
||||
type AdminPaymentOrderResult struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserEmail string `json:"user_email,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
UserNotes *string `json:"user_notes,omitempty"`
|
||||
Amount float64 `json:"amount"`
|
||||
PayAmount float64 `json:"pay_amount"`
|
||||
FeeRate float64 `json:"fee_rate"`
|
||||
Currency string `json:"currency"`
|
||||
RechargeCode string `json:"recharge_code,omitempty"`
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
PaymentType string `json:"payment_type"`
|
||||
PaymentTradeNo string `json:"payment_trade_no,omitempty"`
|
||||
PayURL *string `json:"pay_url,omitempty"`
|
||||
QRCode *string `json:"qr_code,omitempty"`
|
||||
QRCodeImg *string `json:"qr_code_img,omitempty"`
|
||||
OrderType string `json:"order_type"`
|
||||
PlanID *int64 `json:"plan_id,omitempty"`
|
||||
SubscriptionGroupID *int64 `json:"subscription_group_id,omitempty"`
|
||||
SubscriptionDays *int `json:"subscription_days,omitempty"`
|
||||
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
|
||||
ProviderKey *string `json:"provider_key,omitempty"`
|
||||
Status string `json:"status"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundAt *time.Time `json:"refund_at,omitempty"`
|
||||
ForceRefund bool `json:"force_refund,omitempty"`
|
||||
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
|
||||
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
|
||||
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
FailedReason *string `json:"failed_reason,omitempty"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
SrcHost string `json:"src_host,omitempty"`
|
||||
SrcURL *string `json:"src_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*AdminPaymentOrderResult {
|
||||
out := make([]*AdminPaymentOrderResult, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
|
||||
if item := sanitizeAdminPaymentOrderForResponse(order); item != nil {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
|
||||
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPaymentOrderResult {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *order
|
||||
cloned.ProviderSnapshot = nil
|
||||
return &cloned
|
||||
return &AdminPaymentOrderResult{
|
||||
ID: order.ID,
|
||||
UserID: order.UserID,
|
||||
UserEmail: order.UserEmail,
|
||||
UserName: order.UserName,
|
||||
UserNotes: order.UserNotes,
|
||||
Amount: order.Amount,
|
||||
PayAmount: order.PayAmount,
|
||||
FeeRate: order.FeeRate,
|
||||
Currency: service.PaymentOrderCurrency(order),
|
||||
RechargeCode: order.RechargeCode,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
PaymentType: order.PaymentType,
|
||||
PaymentTradeNo: order.PaymentTradeNo,
|
||||
PayURL: order.PayURL,
|
||||
QRCode: order.QrCode,
|
||||
QRCodeImg: order.QrCodeImg,
|
||||
OrderType: order.OrderType,
|
||||
PlanID: order.PlanID,
|
||||
SubscriptionGroupID: order.SubscriptionGroupID,
|
||||
SubscriptionDays: order.SubscriptionDays,
|
||||
ProviderInstanceID: order.ProviderInstanceID,
|
||||
ProviderKey: order.ProviderKey,
|
||||
Status: order.Status,
|
||||
RefundAmount: order.RefundAmount,
|
||||
RefundReason: order.RefundReason,
|
||||
RefundAt: order.RefundAt,
|
||||
ForceRefund: order.ForceRefund,
|
||||
RefundRequestedAt: order.RefundRequestedAt,
|
||||
RefundRequestReason: order.RefundRequestReason,
|
||||
RefundRequestedBy: order.RefundRequestedBy,
|
||||
ExpiresAt: order.ExpiresAt,
|
||||
PaidAt: order.PaidAt,
|
||||
CompletedAt: order.CompletedAt,
|
||||
FailedAt: order.FailedAt,
|
||||
FailedReason: order.FailedReason,
|
||||
ClientIP: order.ClientIP,
|
||||
SrcHost: order.SrcHost,
|
||||
SrcURL: order.SrcURL,
|
||||
CreatedAt: order.CreatedAt,
|
||||
UpdatedAt: order.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminProcessRefundRequest is the request body for admin refund processing.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
)
|
||||
|
||||
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
|
||||
now := time.Now()
|
||||
order := &dbent.PaymentOrder{
|
||||
ID: 1,
|
||||
UserID: 2,
|
||||
Amount: 100,
|
||||
PayAmount: 108,
|
||||
FeeRate: 8,
|
||||
OutTradeNo: "sub2_202606250001",
|
||||
PaymentType: "stripe",
|
||||
OrderType: "subscription",
|
||||
Status: "COMPLETED",
|
||||
ExpiresAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ProviderSnapshot: map[string]any{
|
||||
"schema_version": 2,
|
||||
"currency": "USD",
|
||||
},
|
||||
}
|
||||
|
||||
got := sanitizeAdminPaymentOrderForResponse(order)
|
||||
if got == nil {
|
||||
t.Fatal("expected sanitized order")
|
||||
}
|
||||
if got.Currency != "USD" {
|
||||
t.Fatalf("expected currency USD, got %q", got.Currency)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal sanitized order: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), "provider_snapshot") {
|
||||
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (h *PromoHandler) Update(c *gin.Context) {
|
||||
if req.ExpiresAt != nil {
|
||||
if *req.ExpiresAt == 0 {
|
||||
// 0 表示清除过期时间
|
||||
input.ExpiresAt = nil
|
||||
input.ExpiresAt = &time.Time{}
|
||||
} else {
|
||||
t := time.Unix(*req.ExpiresAt, 0)
|
||||
input.ExpiresAt = &t
|
||||
|
||||
@@ -261,7 +261,12 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||||
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
|
||||
OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin,
|
||||
MinCodexVersion: settings.MinCodexVersion,
|
||||
MaxCodexVersion: settings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
|
||||
CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist,
|
||||
CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients,
|
||||
CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals,
|
||||
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
|
||||
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
|
||||
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
|
||||
@@ -595,7 +600,14 @@ type UpdateSettingsRequest struct {
|
||||
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
|
||||
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
|
||||
OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"`
|
||||
|
||||
// codex_cli_only 加固(global-only)
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
MaxCodexVersion string `json:"max_codex_version"`
|
||||
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
|
||||
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
|
||||
CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"`
|
||||
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
|
||||
|
||||
// Payment visible method routing
|
||||
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
|
||||
@@ -1466,6 +1478,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
|
||||
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
|
||||
response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)")
|
||||
return
|
||||
}
|
||||
if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) {
|
||||
response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)")
|
||||
return
|
||||
}
|
||||
if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 {
|
||||
response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version")
|
||||
return
|
||||
}
|
||||
// codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。
|
||||
// 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。
|
||||
if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号
|
||||
if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" {
|
||||
if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 {
|
||||
@@ -1703,12 +1743,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.OpenAICodexUserAgent
|
||||
}(),
|
||||
OpenAIAllowClaudeCodeCodexPlugin: func() bool {
|
||||
if req.OpenAIAllowClaudeCodeCodexPlugin != nil {
|
||||
return *req.OpenAIAllowClaudeCodeCodexPlugin
|
||||
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
|
||||
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
|
||||
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
|
||||
CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist),
|
||||
CodexCLIOnlyAllowAppServerClients: func() bool {
|
||||
if req.CodexCLIOnlyAllowAppServerClients != nil {
|
||||
return *req.CodexCLIOnlyAllowAppServerClients
|
||||
}
|
||||
return previousSettings.OpenAIAllowClaudeCodeCodexPlugin
|
||||
return previousSettings.CodexCLIOnlyAllowAppServerClients
|
||||
}(),
|
||||
CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals),
|
||||
PaymentVisibleMethodAlipaySource: func() string {
|
||||
if req.PaymentVisibleMethodAlipaySource != nil {
|
||||
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
|
||||
@@ -2100,7 +2145,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
|
||||
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
|
||||
OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin,
|
||||
MinCodexVersion: updatedSettings.MinCodexVersion,
|
||||
MaxCodexVersion: updatedSettings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
|
||||
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
|
||||
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
|
||||
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
|
||||
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
|
||||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||||
@@ -2528,6 +2578,24 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion {
|
||||
changed = append(changed, "max_claude_code_version")
|
||||
}
|
||||
if before.MinCodexVersion != after.MinCodexVersion {
|
||||
changed = append(changed, "min_codex_version")
|
||||
}
|
||||
if before.MaxCodexVersion != after.MaxCodexVersion {
|
||||
changed = append(changed, "max_codex_version")
|
||||
}
|
||||
if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients {
|
||||
changed = append(changed, "codex_cli_only_allow_app_server_clients")
|
||||
}
|
||||
if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals {
|
||||
changed = append(changed, "codex_cli_only_engine_fingerprint_signals")
|
||||
}
|
||||
if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist {
|
||||
changed = append(changed, "codex_cli_only_blacklist")
|
||||
}
|
||||
if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist {
|
||||
changed = append(changed, "codex_cli_only_whitelist")
|
||||
}
|
||||
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
|
||||
changed = append(changed, "allow_ungrouped_key_scheduling")
|
||||
}
|
||||
@@ -2582,9 +2650,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
|
||||
changed = append(changed, "openai_codex_user_agent")
|
||||
}
|
||||
if before.OpenAIAllowClaudeCodeCodexPlugin != after.OpenAIAllowClaudeCodeCodexPlugin {
|
||||
changed = append(changed, "openai_allow_claude_code_codex_plugin")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||
changed = append(changed, "payment_visible_method_alipay_source")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// updateSettingsCodexStatus PUT /settings 仅带给定字段,返回 HTTP 状态码(轻量 stub repo,无 DB)。
|
||||
func updateSettingsCodexStatus(t *testing.T, body map[string]any) int {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyPromoCodeEnabled: "true"}}
|
||||
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.UpdateSettings(c)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// 白名单是双因子 AND:originator-only 条目在运行时永不命中(静默失效)。
|
||||
// handler 应路由到 ValidateCodexWhitelistEntriesJSON,在写入时即拒(400)。
|
||||
func TestUpdateSettings_CodexWhitelistRejectsUnmatchable(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_whitelist": `[{"originator":"opencode"}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusBadRequest, code, "白名单 originator-only 应被拒(静默失效防护)")
|
||||
}
|
||||
|
||||
func TestUpdateSettings_CodexWhitelistAcceptsMatchable(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_whitelist": `[{"originator":"opencode","ua_contains":["opencode/"]}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, code, "可命中白名单条目应通过")
|
||||
}
|
||||
|
||||
// 黑名单是 OR 宽 deny:允许 originator-only。非对称——不受白名单收紧影响。
|
||||
func TestUpdateSettings_CodexBlacklistAllowsOriginatorOnly(t *testing.T) {
|
||||
code := updateSettingsCodexStatus(t, map[string]any{
|
||||
"codex_cli_only_blacklist": `[{"originator":"evil"}]`,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, code, "黑名单 originator-only 应允许(非对称)")
|
||||
}
|
||||
@@ -112,9 +112,9 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
|
||||
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
|
||||
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
|
||||
}
|
||||
// 缓存失效:请求中 2 个 platform + 软删除的 2 个 platform(gemini, antigravity)= 4 次
|
||||
if len(cache.deleteCalls) != 4 {
|
||||
t.Errorf("expected 4 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
// 缓存失效:请求中 2 个 platform + 软删除的 3 个 platform(gemini, antigravity, grok)= 5 次
|
||||
if len(cache.deleteCalls) != 5 {
|
||||
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1374,6 +1374,58 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
|
||||
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
||||
}
|
||||
|
||||
func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
||||
emailVerifyEnabled: true,
|
||||
emailCache: &oauthPendingFlowEmailCacheStub{
|
||||
verificationCodes: map[string]*service.VerificationCodeData{
|
||||
"foo@gmail.com": {
|
||||
Code: "135790",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
|
||||
},
|
||||
},
|
||||
},
|
||||
settingValues: map[string]string{
|
||||
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
|
||||
},
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("suffix-whitelist-session-token").
|
||||
SetIntent("login").
|
||||
SetProviderType("oidc").
|
||||
SetProviderKey("https://issuer.example").
|
||||
SetProviderSubject("oidc-suffix-whitelist-123").
|
||||
SetBrowserSessionKey("suffix-whitelist-browser-session-key").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "oidc_user",
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-whitelist-browser-session-key")})
|
||||
ginCtx.Request = req
|
||||
|
||||
handler.CreateOIDCOAuthAccount(ginCtx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
payload := decodeJSONBody(t, recorder)
|
||||
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"])
|
||||
|
||||
count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count)
|
||||
}
|
||||
|
||||
func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -188,7 +188,14 @@ type SystemSettings struct {
|
||||
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
|
||||
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
|
||||
OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
|
||||
|
||||
// codex_cli_only 加固
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
MaxCodexVersion string `json:"max_codex_version"`
|
||||
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
|
||||
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
|
||||
CodexCLIOnlyAllowAppServerClients bool `json:"codex_cli_only_allow_app_server_clients"`
|
||||
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
|
||||
|
||||
// Web Search Emulation
|
||||
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
|
||||
|
||||
@@ -77,7 +77,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
||||
inbound = strings.TrimSpace(inbound)
|
||||
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits {
|
||||
return inbound
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
@@ -301,14 +302,22 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
reqLog.Warn("gateway.select_account_no_available",
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64p("group_id", apiKey.GroupID),
|
||||
zap.String("platform", platform),
|
||||
zap.Bool("model_not_found", cls.ModelNotFound),
|
||||
zap.Error(err),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
@@ -578,15 +587,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
reqLog.Warn("gateway.select_account_no_available",
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64p("group_id", currentAPIKey.GroupID),
|
||||
zap.String("platform", platform),
|
||||
zap.Bool("fallback_used", fallbackUsed),
|
||||
zap.Bool("model_not_found", cls.ModelNotFound),
|
||||
zap.Error(err),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
@@ -1141,6 +1158,8 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
@@ -1785,8 +1804,11 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, parsedReq.Model)
|
||||
if err != nil {
|
||||
reqLog.Warn("gateway.count_tokens_select_account_failed", zap.Error(err))
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, parsedReq.Model, parsedReq.Model, service.PlatformAnthropic)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
@@ -162,8 +162,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.chatCompletionsErrorResponse(c, cls.Status, cls.ErrType, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
|
||||
@@ -160,8 +160,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available accounts: " + err.Error()
|
||||
}
|
||||
h.responsesErrorResponse(c, cls.Status, cls.ErrType, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(requestCtx)
|
||||
|
||||
@@ -350,8 +350,15 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
|
||||
if err != nil {
|
||||
if len(fs.FailedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available Gemini accounts: " + err.Error()
|
||||
}
|
||||
googleError(c, cls.Status, message)
|
||||
return
|
||||
}
|
||||
action := fs.HandleSelectionExhausted(c.Request.Context())
|
||||
|
||||
@@ -17,6 +17,7 @@ type AdminHandlers struct {
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
GrokOAuth *admin.GrokOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// noAccountErrorClassification describes the HTTP response to emit when
|
||||
// account selection failed with ErrNoAvailableAccounts. Handlers obtain it
|
||||
// via classifyNoAccountError and choose between:
|
||||
//
|
||||
// - 404 model_not_found — the group has accounts, but none of them are
|
||||
// configured to serve the requested model (config / typo / unsupported
|
||||
// model). Returning 503 here misleads operators and trips reverse-proxy
|
||||
// health checks; 404 lets the client surface the real problem.
|
||||
//
|
||||
// - 503 api_error — accounts that could serve the model exist but are
|
||||
// temporarily exhausted (rate limit, quota auto-pause, runtime block) OR
|
||||
// the group has no accounts at all. Both stay on 503 because retrying
|
||||
// after a backoff can plausibly succeed (or, in the empty-pool case, the
|
||||
// operator may be in the middle of adding accounts).
|
||||
type noAccountErrorClassification struct {
|
||||
Status int
|
||||
ErrType string
|
||||
Message string
|
||||
ModelNotFound bool // true when this is a 404 model_not_found classification
|
||||
}
|
||||
|
||||
// classifyNoAccountError decides between 404 model_not_found and 503
|
||||
// api_error for "no available accounts" failures.
|
||||
//
|
||||
// The classifier intentionally does not consume the original error: the
|
||||
// selection layer never tells us *why* the pool came up empty (rate-limited
|
||||
// vs. unsupported model are both wrapped as ErrNoAvailableAccounts). Instead
|
||||
// we re-check pool composition through DiagnoseModelAvailabilityForPlatform,
|
||||
// which only inspects model_mapping configuration and ignores transient
|
||||
// state. That guarantees a 404 is only returned when no operator action
|
||||
// short of editing the account's model_mapping could make this request
|
||||
// succeed.
|
||||
//
|
||||
// routingModel is the model name that account selection actually compared
|
||||
// against (i.e. after group-level dispatch mapping). displayModel is the
|
||||
// raw model the caller asked for; it is used only in the user-facing error
|
||||
// message so that internal mapping details don't leak. Most callers pass
|
||||
// the same value for both.
|
||||
//
|
||||
// platform is the platform the request was routed to (use
|
||||
// service.PlatformOpenAI / PlatformAnthropic / PlatformGemini). It is
|
||||
// required because Anthropic/Gemini routes additionally surface
|
||||
// mixed-scheduled Antigravity accounts; passing the wrong platform would
|
||||
// flip a legitimate 503 to a misleading 404 (or vice versa).
|
||||
func classifyNoAccountError(
|
||||
ctx context.Context,
|
||||
diag service.ModelAvailabilityDiagnoser,
|
||||
apiKey *service.APIKey,
|
||||
routingModel string,
|
||||
displayModel string,
|
||||
platform string,
|
||||
) noAccountErrorClassification {
|
||||
fallback := noAccountErrorClassification{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
ErrType: "api_error",
|
||||
Message: "Service temporarily unavailable",
|
||||
}
|
||||
|
||||
routingModel = strings.TrimSpace(routingModel)
|
||||
displayModel = strings.TrimSpace(displayModel)
|
||||
if displayModel == "" {
|
||||
displayModel = routingModel
|
||||
}
|
||||
if diag == nil || apiKey == nil || apiKey.GroupID == nil || routingModel == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
|
||||
if result.HasAccountsInPool && !result.HasModelSupport {
|
||||
return noAccountErrorClassification{
|
||||
Status: http.StatusNotFound,
|
||||
ErrType: "model_not_found",
|
||||
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
|
||||
ModelNotFound: true,
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// classifyNoAccountErrorFromGin is a thin wrapper that forwards the gin
|
||||
// context's underlying request context. Most call sites already have a
|
||||
// *gin.Context handy, so this keeps the call sites uncluttered.
|
||||
func classifyNoAccountErrorFromGin(
|
||||
c *gin.Context,
|
||||
diag service.ModelAvailabilityDiagnoser,
|
||||
apiKey *service.APIKey,
|
||||
routingModel string,
|
||||
displayModel string,
|
||||
platform string,
|
||||
) noAccountErrorClassification {
|
||||
ctx := context.Background()
|
||||
if c != nil && c.Request != nil {
|
||||
ctx = c.Request.Context()
|
||||
}
|
||||
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type fakeDiagnoser struct {
|
||||
calls []fakeDiagnoseCall
|
||||
resp service.ModelAvailabilityDiagnosis
|
||||
}
|
||||
|
||||
type fakeDiagnoseCall struct {
|
||||
GroupID *int64
|
||||
Model string
|
||||
Platform string
|
||||
}
|
||||
|
||||
func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
|
||||
_ context.Context,
|
||||
groupID *int64,
|
||||
model, platform string,
|
||||
) service.ModelAvailabilityDiagnosis {
|
||||
f.calls = append(f.calls, fakeDiagnoseCall{
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
Platform: platform,
|
||||
})
|
||||
return f.resp
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 { return &v }
|
||||
|
||||
// newTestGinContextWithRequest wraps the bare newTestGinContext helper
|
||||
// (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
|
||||
// *http.Request so the classifier can extract c.Request.Context().
|
||||
func newTestGinContextWithRequest() *gin.Context {
|
||||
c := newTestGinContext()
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.Equal(t, "api_error", cls.ErrType)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: nil}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
require.Empty(t, fd.calls)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(42)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||||
require.Equal(t, "model_not_found", cls.ErrType)
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
|
||||
|
||||
require.Len(t, fd.calls, 1)
|
||||
require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
|
||||
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
|
||||
require.NotNil(t, fd.calls[0].GroupID)
|
||||
require.Equal(t, int64(42), *fd.calls[0].GroupID)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
|
||||
require.Equal(t, "api_error", cls.ErrType)
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
|
||||
require.False(t, cls.ModelNotFound)
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
|
||||
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
|
||||
require.Len(t, fd.calls, 1)
|
||||
require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
|
||||
}
|
||||
|
||||
func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
|
||||
require.True(t, cls.ModelNotFound)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
requestPlatform := openAICompatibleRequestPlatform(apiKey)
|
||||
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
routingStart := time.Now()
|
||||
@@ -144,6 +145,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
requestPlatform,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai_chat_completions.account_select_failed",
|
||||
@@ -151,8 +153,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
} else {
|
||||
if lastFailoverErr != nil {
|
||||
@@ -164,8 +169,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
account := selection.Account
|
||||
@@ -289,7 +297,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := resolveRawCCUpstreamEndpoint(c, account)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
|
||||
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
@@ -325,12 +333,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
|
||||
// OpenAI Chat Completions requests. For APIKey accounts whose upstream
|
||||
// is forced or probed to not support the Responses API, the request is
|
||||
// forwarded directly to /v1/chat/completions — not through the default
|
||||
// CC→Responses conversion path.
|
||||
func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string {
|
||||
// resolveOpenAIUpstreamEndpoint returns the actual upstream endpoint for an
|
||||
// OpenAI account, used by every OpenAI usage-recording site. APIKey accounts
|
||||
// whose upstream is forced or probed to not support the Responses API are
|
||||
// served directly via /v1/chat/completions (the raw chat path) regardless of
|
||||
// the inbound endpoint; everything else goes through the Responses API.
|
||||
func resolveOpenAIUpstreamEndpoint(c *gin.Context, account *service.Account) string {
|
||||
if account != nil && account.Type == service.AccountTypeAPIKey &&
|
||||
!openai_compat.ShouldUseResponsesAPI(account.Extra) {
|
||||
return "/v1/chat/completions"
|
||||
|
||||
@@ -124,8 +124,11 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
if lastFailoverErr != nil {
|
||||
@@ -136,8 +139,11 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||
return
|
||||
}
|
||||
account := selection.Account
|
||||
|
||||
@@ -97,6 +97,13 @@ func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecord
|
||||
}
|
||||
}
|
||||
|
||||
func openAICompatibleRequestPlatform(apiKey *service.APIKey) string {
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformGrok {
|
||||
return service.PlatformGrok
|
||||
}
|
||||
return service.PlatformOpenAI
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
|
||||
func NewOpenAIGatewayHandler(
|
||||
gatewayService *service.OpenAIGatewayService,
|
||||
@@ -282,6 +289,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
|
||||
// Get subscription info (may be nil)
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
requestPlatform := openAICompatibleRequestPlatform(apiKey)
|
||||
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
routingStart := time.Now()
|
||||
@@ -332,6 +340,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
requireCompact,
|
||||
requestPlatform,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_select_failed",
|
||||
@@ -339,12 +348,16 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
if errors.Is(err, service.ErrNoAvailableCompactAccounts) {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted)
|
||||
return
|
||||
}
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
if lastFailoverErr != nil {
|
||||
@@ -355,8 +368,11 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
if previousResponseID != "" && selection != nil && selection.Account != nil {
|
||||
@@ -496,7 +512,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
clientIP := ip.GetClientIP(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
|
||||
|
||||
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
@@ -701,6 +717,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
requestPlatform := openAICompatibleRequestPlatform(apiKey)
|
||||
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
routingStart := time.Now()
|
||||
@@ -753,6 +770,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
requestPlatform,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai_messages.account_select_failed",
|
||||
@@ -761,8 +779,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
if err != nil {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -775,8 +796,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
|
||||
return
|
||||
}
|
||||
account := selection.Account
|
||||
@@ -901,7 +925,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
clientIP := ip.GetClientIP(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
|
||||
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
|
||||
@@ -1309,6 +1333,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
requestPlatform := openAICompatibleRequestPlatform(apiKey)
|
||||
if err := h.billingCacheService.CheckBillingEligibility(ctx, apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||
reqLog.Info("openai.websocket_billing_eligibility_check_failed", zap.Error(err))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
|
||||
@@ -1337,6 +1362,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
service.OpenAIUpstreamTransportResponsesWebsocketV2,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
false,
|
||||
requestPlatform,
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.websocket_account_select_failed",
|
||||
@@ -1496,7 +1522,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
}
|
||||
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
|
||||
@@ -2297,7 +2323,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
|
||||
var accountID int64
|
||||
if account != nil {
|
||||
accountID = account.ID
|
||||
upstreamEndpoint = GetUpstreamEndpoint(c, account.Platform)
|
||||
upstreamEndpoint = resolveOpenAIUpstreamEndpoint(c, account)
|
||||
}
|
||||
stream := false
|
||||
if v, ok := c.Get(opsStreamKey); ok {
|
||||
|
||||
@@ -1341,6 +1341,7 @@ func TestOpenAIResponsesWebSocket_FailoverOnUpstreamUsageLimitEvent(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
cache := &concurrencyCacheMock{
|
||||
@@ -1523,6 +1524,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
&service.DeferredService{},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
channelSvc,
|
||||
nil,
|
||||
nil,
|
||||
|
||||
@@ -159,8 +159,15 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
zap.Int("excluded_account_count", len(failedAccountIDs)),
|
||||
)
|
||||
if len(failedAccountIDs) == 0 {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available compatible accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available compatible accounts"
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
if lastFailoverErr != nil {
|
||||
@@ -171,8 +178,15 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available compatible accounts", streamStarted)
|
||||
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformOpenAI)
|
||||
if !cls.ModelNotFound {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
}
|
||||
message := cls.Message
|
||||
if !cls.ModelNotFound {
|
||||
message = "No available compatible accounts"
|
||||
}
|
||||
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billingService.Stop)
|
||||
|
||||
@@ -20,6 +20,7 @@ func ProvideAdminHandlers(
|
||||
openaiOAuthHandler *admin.OpenAIOAuthHandler,
|
||||
geminiOAuthHandler *admin.GeminiOAuthHandler,
|
||||
antigravityOAuthHandler *admin.AntigravityOAuthHandler,
|
||||
grokOAuthHandler *admin.GrokOAuthHandler,
|
||||
proxyHandler *admin.ProxyHandler,
|
||||
redeemHandler *admin.RedeemHandler,
|
||||
promoHandler *admin.PromoHandler,
|
||||
@@ -53,6 +54,7 @@ func ProvideAdminHandlers(
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
GrokOAuth: grokOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
@@ -167,6 +169,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewOpenAIOAuthHandler,
|
||||
admin.NewGeminiOAuthHandler,
|
||||
admin.NewAntigravityOAuthHandler,
|
||||
admin.NewGrokOAuthHandler,
|
||||
admin.NewProxyHandler,
|
||||
admin.NewRedeemHandler,
|
||||
admin.NewPromoHandler,
|
||||
|
||||
@@ -36,11 +36,12 @@ const (
|
||||
PlatformOpenAI = "openai"
|
||||
PlatformGemini = "gemini"
|
||||
PlatformAntigravity = "antigravity"
|
||||
PlatformGrok = "grok"
|
||||
)
|
||||
|
||||
// AllPlatforms 返回所有支持的平台列表
|
||||
func AllPlatforms() []string {
|
||||
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity}
|
||||
return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok}
|
||||
}
|
||||
|
||||
// Validate 验证规则配置的有效性
|
||||
|
||||
@@ -737,6 +737,13 @@ func ChatCompletionsChunkToResponsesEvents(
|
||||
copyCall.ID = generateItemID()
|
||||
}
|
||||
copyCall.Type = "function"
|
||||
// Arguments are accumulated by the shared block below so the
|
||||
// emitted delta and the stored value stay in sync. Some upstreams
|
||||
// (e.g. GLM/Zhipu) pack id+name+arguments into the first tool_call
|
||||
// chunk; without this reset the first chunk's arguments would be
|
||||
// counted twice (once from this copy, once from the += below),
|
||||
// producing a doubled, invalid JSON like {"a":1}{"a":1}.
|
||||
copyCall.Function.Arguments = ""
|
||||
state.ToolCalls[idx] = ©Call
|
||||
stored = ©Call
|
||||
itemID := generateItemID()
|
||||
|
||||
@@ -179,6 +179,41 @@ func TestStream_ToolCallLifecycleComplete(t *testing.T) {
|
||||
require.True(t, sawItemDone, "function_call output_item.done missing")
|
||||
}
|
||||
|
||||
// TestStream_ToolCallArgumentsInFirstChunkNotDoubled guards the GLM/Zhipu shape
|
||||
// where a single tool_call delta chunk carries id+name+arguments together.
|
||||
// Earlier code copied the whole tool_call (including arguments) into state and
|
||||
// then accumulated the same chunk's arguments again, producing a doubled,
|
||||
// invalid JSON like {"cmd":"ls"}{"cmd":"ls"} that breaks Codex tool parsing
|
||||
// ("trailing characters").
|
||||
func TestStream_ToolCallArgumentsInFirstChunkNotDoubled(t *testing.T) {
|
||||
events := collectStreamEvents(t, []string{
|
||||
`{"choices":[{"index":0,"delta":{"role":"assistant"}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"exec","arguments":"{\"cmd\":\"ls\"}"}}]}}]}`,
|
||||
`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
})
|
||||
|
||||
var argsDelta strings.Builder
|
||||
var sawArgsDone, sawItemDone bool
|
||||
for _, e := range events {
|
||||
switch e.Type {
|
||||
case "response.function_call_arguments.delta":
|
||||
_, _ = argsDelta.WriteString(e.Delta)
|
||||
case "response.function_call_arguments.done":
|
||||
sawArgsDone = true
|
||||
require.Equal(t, `{"cmd":"ls"}`, e.Arguments)
|
||||
case "response.output_item.done":
|
||||
if e.Item != nil && e.Item.Type == "function_call" {
|
||||
sawItemDone = true
|
||||
require.Equal(t, `{"cmd":"ls"}`, e.Item.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, sawArgsDone, "function_call_arguments.done missing")
|
||||
require.True(t, sawItemDone, "function_call output_item.done missing")
|
||||
// Accumulated deltas must equal the final arguments exactly (no duplication).
|
||||
require.Equal(t, `{"cmd":"ls"}`, argsDelta.String())
|
||||
}
|
||||
|
||||
// TestStream_SSEWireComplete drives the full stream through SSE encoding and
|
||||
// asserts the function_call events carry complete fields on the wire.
|
||||
func TestStream_SSEWireComplete(t *testing.T) {
|
||||
|
||||
@@ -524,25 +524,58 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
|
||||
Description: t.Description,
|
||||
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
|
||||
})
|
||||
case "custom":
|
||||
out = append(out, AnthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
|
||||
})
|
||||
default:
|
||||
// Pass through unknown tool types
|
||||
out = append(out, AnthropicTool{
|
||||
Type: t.Type,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.Parameters,
|
||||
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeAnthropicInputSchema ensures the input_schema has a "type" field.
|
||||
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
|
||||
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
|
||||
if len(schema) == 0 || string(schema) == "null" {
|
||||
const emptyObjectSchema = `{"type":"object","properties":{}}`
|
||||
|
||||
trimmed := strings.TrimSpace(string(schema))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return json.RawMessage(emptyObjectSchema)
|
||||
}
|
||||
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(schema, &m); err != nil {
|
||||
return json.RawMessage(`{"type":"object","properties":{}}`)
|
||||
}
|
||||
return schema
|
||||
|
||||
typeRaw, ok := m["type"]
|
||||
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
|
||||
m["type"] = json.RawMessage(`"object"`)
|
||||
} else {
|
||||
var typ string
|
||||
if err := json.Unmarshal(typeRaw, &typ); err != nil || typ != "object" {
|
||||
return json.RawMessage(emptyObjectSchema)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := m["properties"]; !ok {
|
||||
m["properties"] = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return json.RawMessage(emptyObjectSchema)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// convertResponsesToAnthropicToolChoice maps Responses tool_choice to Anthropic format.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func requireObjectInputSchema(t *testing.T, schema json.RawMessage) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, schema)
|
||||
|
||||
var parsed map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(schema, &parsed))
|
||||
require.JSONEq(t, `"object"`, string(parsed["type"]))
|
||||
require.Contains(t, parsed, "properties")
|
||||
|
||||
var properties map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(parsed["properties"], &properties))
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_CustomGrammarToolUsesObjectSchema(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"model": "gpt-5.2",
|
||||
"input": "apply this patch",
|
||||
"tools": [{
|
||||
"type": "custom",
|
||||
"name": "apply_patch",
|
||||
"description": "Apply a patch to the working tree",
|
||||
"format": {
|
||||
"type": "grammar",
|
||||
"syntax": "lark",
|
||||
"definition": "start: /.+/"
|
||||
}
|
||||
}]
|
||||
}`)
|
||||
|
||||
var req ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal(body, &req))
|
||||
|
||||
anthropicReq, err := ResponsesToAnthropicRequest(&req)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, anthropicReq.Tools, 1)
|
||||
|
||||
tool := anthropicReq.Tools[0]
|
||||
assert.Empty(t, tool.Type)
|
||||
assert.Equal(t, "apply_patch", tool.Name)
|
||||
assert.Equal(t, "Apply a patch to the working tree", tool.Description)
|
||||
requireObjectInputSchema(t, tool.InputSchema)
|
||||
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tool.InputSchema))
|
||||
|
||||
wire, err := json.Marshal(tool)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(wire), `"type":"custom"`)
|
||||
assert.NotContains(t, string(wire), `"format"`)
|
||||
assert.NotContains(t, string(wire), `"grammar"`)
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_CustomToolPreservesSchemaParameters(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "custom",
|
||||
Name: "edit_file",
|
||||
Description: "Edit a file",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{"patch":{"type":"string"}},"required":["patch"]}`),
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
assert.Empty(t, tools[0].Type)
|
||||
assert.Equal(t, "edit_file", tools[0].Name)
|
||||
|
||||
schema := requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
assert.JSONEq(t, `{"patch":{"type":"string"}}`, string(schema["properties"]))
|
||||
assert.JSONEq(t, `["patch"]`, string(schema["required"]))
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_FunctionToolSchemaUnchanged(t *testing.T) {
|
||||
parameters := json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`)
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "function",
|
||||
Name: "get_weather",
|
||||
Description: "Get weather",
|
||||
Parameters: parameters,
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
assert.Empty(t, tools[0].Type)
|
||||
assert.Equal(t, "get_weather", tools[0].Name)
|
||||
assert.Equal(t, "Get weather", tools[0].Description)
|
||||
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: "read_file",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`),
|
||||
},
|
||||
{
|
||||
Type: "custom",
|
||||
Name: "apply_patch",
|
||||
},
|
||||
{
|
||||
Type: "web_search",
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, tools, 3)
|
||||
assert.Empty(t, tools[0].Type)
|
||||
assert.Equal(t, "read_file", tools[0].Name)
|
||||
requireObjectInputSchema(t, tools[0].InputSchema)
|
||||
|
||||
assert.Empty(t, tools[1].Type)
|
||||
assert.Equal(t, "apply_patch", tools[1].Name)
|
||||
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[1].InputSchema))
|
||||
|
||||
assert.Equal(t, "web_search_20250305", tools[2].Type)
|
||||
assert.Equal(t, "web_search", tools[2].Name)
|
||||
assert.Empty(t, tools[2].InputSchema)
|
||||
}
|
||||
|
||||
func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
|
||||
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
|
||||
Type: "local_shell",
|
||||
Name: "shell",
|
||||
}})
|
||||
|
||||
require.Len(t, tools, 1)
|
||||
assert.Equal(t, "local_shell", tools[0].Type)
|
||||
assert.Equal(t, "shell", tools[0].Name)
|
||||
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
|
||||
}
|
||||
@@ -2,34 +2,35 @@ package openai
|
||||
|
||||
import "strings"
|
||||
|
||||
// 命名预设 ID。账号侧 codex_cli_only_allowed_clients 只能引用这些预设键,
|
||||
// 具体匹配规则固化在下方 registry 中,配置只能「选择启用哪些预设」、不能自定义规则,
|
||||
// 以防该白名单退化为可任意放宽的后门。
|
||||
const (
|
||||
// AllowedClientClaudeCode 对应 Claude Code CLI 的 codex 插件。
|
||||
AllowedClientClaudeCode = "claude_code"
|
||||
)
|
||||
|
||||
// AllowedClientEntry 描述一个被额外放行的非官方 Codex 客户端签名。
|
||||
// Originator 必须精确等值匹配(归一化后)。
|
||||
// UAContains 为必填字段:列表为空,或列表中存在任何空白 marker,均视为非法配置,
|
||||
// 整体安全失败(return false);每一项都必须出现在 User-Agent 中。
|
||||
// 这确保双因子匹配不会因缺失 UA 声明而退化为仅凭可伪造的 originator 单因子放行。
|
||||
// SkipEngineFingerprint 仅对白名单条目有意义:命中此条则跳过引擎指纹门(管理员显式承担
|
||||
// "纯 UA+originator、无引擎兜底"的后门风险,默认 false)。黑名单忽略此字段。
|
||||
type AllowedClientEntry struct {
|
||||
Originator string
|
||||
UAContains []string
|
||||
Originator string `json:"originator"`
|
||||
UAContains []string `json:"ua_contains"`
|
||||
SkipEngineFingerprint bool `json:"skip_engine_fingerprint"`
|
||||
}
|
||||
|
||||
// allowedClientRegistry 固化各命名预设的签名规则。
|
||||
//
|
||||
// Claude Code codex 插件签名来源:插件以 clientInfo.name="Claude Code" 完成 app-server
|
||||
// initialize 握手,codex 据此把 originator 设为 "Claude Code",User-Agent 前缀同样为
|
||||
// "Claude Code/"(两者同源)。若上游 Claude Code 插件更改 clientInfo.name,此处需同步更新。
|
||||
var allowedClientRegistry = map[string]AllowedClientEntry{
|
||||
AllowedClientClaudeCode: {
|
||||
Originator: "Claude Code",
|
||||
UAContains: []string{"Claude Code/"},
|
||||
},
|
||||
// IsWhitelistable 报告该条目作为白名单条目是否「有可能命中」——镜像 IsAllowedClientMatch 的结构性
|
||||
// 前置:originator 非空、ua_contains 至少一项、且无任何空白 marker(空白 marker 会让整条永不命中)。
|
||||
// 仅供管理端写入校验,避免存入静默失效的白名单规则。黑名单(OR 宽 deny,允许 originator-only)不受此约束。
|
||||
func (e AllowedClientEntry) IsWhitelistable() bool {
|
||||
if normalizeCodexClientHeader(e.Originator) == "" {
|
||||
return false
|
||||
}
|
||||
if len(e.UAContains) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, marker := range e.UAContains {
|
||||
if normalizeCodexClientHeader(marker) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsAllowedClientMatch 判断请求头是否命中给定的额外客户端签名。
|
||||
@@ -62,15 +63,47 @@ func IsAllowedClientMatch(userAgent, originator string, entry AllowedClientEntry
|
||||
return true
|
||||
}
|
||||
|
||||
// MatchAllowedClients 判断请求头是否命中 clientIDs 引用的任一预设签名。
|
||||
// 未知预设 ID 会被忽略;空列表恒不放行(默认拒绝)。
|
||||
func MatchAllowedClients(userAgent, originator string, clientIDs []string) bool {
|
||||
for _, id := range clientIDs {
|
||||
entry, ok := allowedClientRegistry[normalizeCodexClientHeader(id)]
|
||||
if !ok {
|
||||
continue
|
||||
// MatchClientEntry 同 MatchClientEntries(双因子 AND,复用 IsAllowedClientMatch),但回传命中的
|
||||
// 那条条目,供调用方读取 SkipEngineFingerprint 等条目级配置。未命中返回零值 + false。
|
||||
func MatchClientEntry(userAgent, originator string, entries []AllowedClientEntry) (AllowedClientEntry, bool) {
|
||||
for _, e := range entries {
|
||||
if IsAllowedClientMatch(userAgent, originator, e) {
|
||||
return e, true
|
||||
}
|
||||
if IsAllowedClientMatch(userAgent, originator, entry) {
|
||||
}
|
||||
return AllowedClientEntry{}, false
|
||||
}
|
||||
|
||||
// MatchClientEntries 判断请求头是否命中任一白名单自由条目(双因子 AND)。薄封装 MatchClientEntry。
|
||||
// 用于 codex_cli_only 全局白名单:放行官方集未覆盖的 app-server 新 client。
|
||||
func MatchClientEntries(userAgent, originator string, entries []AllowedClientEntry) bool {
|
||||
_, ok := MatchClientEntry(userAgent, originator, entries)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsDeniedClientMatch 黑名单单条 OR 语义:已声明字段中任一命中即 deny。
|
||||
// originator 精确等值命中,或任一非空 ua_contains marker 出现在 UA 中。
|
||||
// 全空字段(originator 与 ua_contains 均空)→ 不 deny(安全忽略)。
|
||||
// 与白名单 AND 非对称:deny 应宽(挡可疑),allow 应严(防伪造)。
|
||||
func IsDeniedClientMatch(userAgent, originator string, entry AllowedClientEntry) bool {
|
||||
if want := normalizeCodexClientHeader(entry.Originator); want != "" {
|
||||
if normalizeCodexClientHeader(originator) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ua := normalizeCodexClientHeader(userAgent)
|
||||
for _, marker := range entry.UAContains {
|
||||
if m := normalizeCodexClientHeader(marker); m != "" && strings.Contains(ua, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchDenyEntries 判断请求头是否命中任一黑名单条目(OR)。
|
||||
func MatchDenyEntries(userAgent, originator string, entries []AllowedClientEntry) bool {
|
||||
for _, e := range entries {
|
||||
if IsDeniedClientMatch(userAgent, originator, e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchClientEntries_WhitelistAND(t *testing.T) {
|
||||
wl := []AllowedClientEntry{{Originator: "opencode", UAContains: []string{"opencode/"}}}
|
||||
require.True(t, MatchClientEntries("opencode/1.2 (x)", "opencode", wl))
|
||||
require.False(t, MatchClientEntries("opencode/1.2 (x)", "other", wl), "originator 不符不放")
|
||||
require.False(t, MatchClientEntries("curl/8", "opencode", wl), "UA marker 缺失不放")
|
||||
require.False(t, MatchClientEntries("opencode/1.2", "opencode", []AllowedClientEntry{{Originator: "opencode"}}), "空 UAContains 安全失败")
|
||||
}
|
||||
|
||||
func TestDenyEntries_BlacklistOR(t *testing.T) {
|
||||
bl := []AllowedClientEntry{
|
||||
{Originator: "evilbot"},
|
||||
{UAContains: []string{"badscan/"}},
|
||||
}
|
||||
require.True(t, MatchDenyEntries("anything/1", "evilbot", bl), "originator 命中即拒")
|
||||
require.True(t, MatchDenyEntries("badscan/9 (x)", "whatever", bl), "UA marker 命中即拒")
|
||||
require.False(t, MatchDenyEntries("codex_cli_rs/0.141.0", "codex_cli_rs", bl), "都不命中不拒")
|
||||
require.False(t, MatchDenyEntries("x", "y", []AllowedClientEntry{{}}), "全空条目安全忽略")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchClientEntry_ReturnsHitEntry(t *testing.T) {
|
||||
entries := []AllowedClientEntry{
|
||||
{Originator: "opencode", UAContains: []string{"opencode/"}, SkipEngineFingerprint: true},
|
||||
{Originator: "Claude Code", UAContains: []string{"Claude Code/"}},
|
||||
}
|
||||
|
||||
e, ok := MatchClientEntry("opencode/1.0", "opencode", entries)
|
||||
require.True(t, ok)
|
||||
require.True(t, e.SkipEngineFingerprint)
|
||||
|
||||
e2, ok2 := MatchClientEntry("Claude Code/1.0 (x) (Claude Code; 1)", "Claude Code", entries)
|
||||
require.True(t, ok2)
|
||||
require.False(t, e2.SkipEngineFingerprint)
|
||||
|
||||
_, ok3 := MatchClientEntry("curl/8", "evil", entries)
|
||||
require.False(t, ok3)
|
||||
|
||||
// 薄封装保持兼容
|
||||
require.True(t, MatchClientEntries("opencode/1.0", "opencode", entries))
|
||||
require.False(t, MatchClientEntries("curl/8", "evil", entries))
|
||||
}
|
||||
@@ -68,28 +68,3 @@ func TestIsAllowedClientMatch_MixedEmptyUAMarkerNeverMatches(t *testing.T) {
|
||||
t.Fatal("UAContains 混入空白 marker 不应匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAllowedClients(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
originator string
|
||||
clientIDs []string
|
||||
want bool
|
||||
}{
|
||||
{name: "claude_code 预设命中真实签名", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{AllowedClientClaudeCode}, want: true},
|
||||
{name: "claude_code 预设 + 伪造 originator 不命中", ua: testClaudeCodeUserAgent, originator: "my_client", clientIDs: []string{AllowedClientClaudeCode}, want: false},
|
||||
{name: "空列表不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: nil, want: false},
|
||||
{name: "未知预设 ID 不放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client"}, want: false},
|
||||
{name: "ID 大小写/空白容错", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{" Claude_Code "}, want: true},
|
||||
{name: "多预设任一命中即放行", ua: testClaudeCodeUserAgent, originator: testClaudeCodeOriginator, clientIDs: []string{"unknown_client", AllowedClientClaudeCode}, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := MatchAllowedClients(tt.ua, tt.originator, tt.clientIDs); got != tt.want {
|
||||
t.Fatalf("MatchAllowedClients(%q, %q, %v) = %v, want %v", tt.ua, tt.originator, tt.clientIDs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package openai
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowedClientEntry_IsWhitelistable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
entry AllowedClientEntry
|
||||
want bool
|
||||
}{
|
||||
{name: "完整条目可白名单", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"opencode/"}}, want: true},
|
||||
{name: "多个有效 marker 可白名单", entry: AllowedClientEntry{Originator: "x", UAContains: []string{"a/", "b/"}}, want: true},
|
||||
{name: "缺 originator → 不可(静默失效)", entry: AllowedClientEntry{UAContains: []string{"opencode/"}}, want: false},
|
||||
{name: "originator 全空白 → 不可", entry: AllowedClientEntry{Originator: " ", UAContains: []string{"opencode/"}}, want: false},
|
||||
{name: "缺 ua_contains → 不可(静默失效)", entry: AllowedClientEntry{Originator: "opencode"}, want: false},
|
||||
{name: "ua_contains 全空白 → 不可", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"", " "}}, want: false},
|
||||
{name: "含一个空白 marker → 不可(空白会让整条 IsAllowedClientMatch 永不命中)", entry: AllowedClientEntry{Originator: "opencode", UAContains: []string{"opencode/", ""}}, want: false},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.entry.IsWhitelistable(); got != tt.want {
|
||||
t.Fatalf("IsWhitelistable(%+v) = %v, want %v", tt.entry, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,9 @@ const DefaultTestModel = "gpt-5.4"
|
||||
//go:embed instructions.txt
|
||||
var DefaultInstructions string
|
||||
|
||||
// instructionsGPT51 / instructionsGPT52 为 gpt-5.1 / gpt-5.2 非 codex 模型对应的
|
||||
// 真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。
|
||||
// instructionsGPT51 / instructionsGPT52 / instructionsGPT55 为 gpt-5.1 / gpt-5.2 / gpt-5.5
|
||||
// 非 codex 模型对应的真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。
|
||||
// GPT-5.5 同时作为最新版本的 fallback(覆盖 5.3 / 5.4 等未单独维护 prompt 的版本)。
|
||||
//
|
||||
//go:embed instructions_gpt5_1.txt
|
||||
var instructionsGPT51 string
|
||||
@@ -57,26 +58,41 @@ var instructionsGPT51 string
|
||||
//go:embed instructions_gpt5_2.txt
|
||||
var instructionsGPT52 string
|
||||
|
||||
//go:embed instructions_gpt5_5.txt
|
||||
var instructionsGPT55 string
|
||||
|
||||
// latestCodexInstructions 返回当前已知最新版本的 Codex base instructions,
|
||||
// 当前为 GPT-5.5;若 5.5 prompt 意外为空则回退到 DefaultInstructions 保证非空。
|
||||
func latestCodexInstructions() string {
|
||||
if v := strings.TrimSpace(instructionsGPT55); v != "" {
|
||||
return instructionsGPT55
|
||||
}
|
||||
return DefaultInstructions
|
||||
}
|
||||
|
||||
// CodexBaseInstructionsForModel 按模型返回最匹配的真实 Codex base instructions:
|
||||
// - 含 "codex" 的模型(gpt-5-codex / gpt-5.x-codex / codex-max / spark 等)→ GPT-5-Codex prompt
|
||||
// - gpt-5.5 系非 codex 模型 → GPT-5.5 prompt
|
||||
// - gpt-5.2 系非 codex 模型 → GPT-5.2 prompt
|
||||
// - gpt-5.1 / gpt-5 系非 codex 模型 → GPT-5.1 prompt
|
||||
// - 其它 → 回退到 GPT-5-Codex prompt
|
||||
// - gpt-5.1 系非 codex 模型 → GPT-5.1 prompt
|
||||
// - 其它(含 gpt-5.3 / gpt-5.4 / 裸 gpt-5 / 未知模型)→ 回退到最新版本(当前 GPT-5.5)
|
||||
//
|
||||
// 任一专用 prompt 意外为空时回退到 DefaultInstructions,保证返回非空。
|
||||
// 任一专用 prompt 意外为空时回退链最终落到 DefaultInstructions,保证返回非空。
|
||||
func CodexBaseInstructionsForModel(model string) string {
|
||||
m := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case strings.Contains(m, "codex"):
|
||||
return DefaultInstructions
|
||||
case strings.HasPrefix(m, "gpt-5.5"):
|
||||
return latestCodexInstructions()
|
||||
case strings.HasPrefix(m, "gpt-5.2"):
|
||||
if v := strings.TrimSpace(instructionsGPT52); v != "" {
|
||||
return instructionsGPT52
|
||||
}
|
||||
case strings.HasPrefix(m, "gpt-5.1"), strings.HasPrefix(m, "gpt-5"):
|
||||
case strings.HasPrefix(m, "gpt-5.1"):
|
||||
if v := strings.TrimSpace(instructionsGPT51); v != "" {
|
||||
return instructionsGPT51
|
||||
}
|
||||
}
|
||||
return DefaultInstructions
|
||||
return latestCodexInstructions()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// EngineFingerprintSignal 描述引擎指纹统一列表的一条信号。
|
||||
// Required=true(勾选)= 该信号必须命中;多条 Required 之间 AND。
|
||||
// Match 为同一信号的等价写法/变体,行内 OR(命中任一即算该条满足)。
|
||||
type EngineFingerprintSignal struct {
|
||||
Type string `json:"type"` // header_exact | header_prefix | body_path
|
||||
Match []string `json:"match"` // 行内 OR 变体
|
||||
Required bool `json:"required"` // 勾选=true
|
||||
}
|
||||
|
||||
const (
|
||||
FingerprintSignalHeaderExact = "header_exact"
|
||||
FingerprintSignalHeaderPrefix = "header_prefix"
|
||||
FingerprintSignalBodyPath = "body_path"
|
||||
)
|
||||
|
||||
// DefaultEngineFingerprintSignals 默认种子:只勾 x-codex- 前缀,其余预填不勾。
|
||||
// 依据:实测真 codex(含旧版)约 98.8% 必带 x-codex-window-id 头。
|
||||
var DefaultEngineFingerprintSignals = []EngineFingerprintSignal{
|
||||
{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: true},
|
||||
{Type: FingerprintSignalHeaderExact, Match: []string{"session-id", "session_id"}, Required: false},
|
||||
{Type: FingerprintSignalHeaderExact, Match: []string{"thread-id", "thread_id"}, Required: false},
|
||||
{Type: FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id", "client_metadata.x-codex-installation-id"}, Required: false},
|
||||
}
|
||||
|
||||
// EvaluateEngineFingerprint 应用「勾选 AND / 行内变体 OR」规则。
|
||||
// 只有 Required=true 的条目参与;全部命中→true;任一缺失→false;无任何 Required→true。
|
||||
func EvaluateEngineFingerprint(h http.Header, body []byte, signals []EngineFingerprintSignal) bool {
|
||||
for _, s := range signals {
|
||||
if !s.Required {
|
||||
continue
|
||||
}
|
||||
if !engineSignalMatches(h, body, s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func engineSignalMatches(h http.Header, body []byte, s EngineFingerprintSignal) bool {
|
||||
switch s.Type {
|
||||
case FingerprintSignalHeaderExact:
|
||||
for _, name := range s.Match {
|
||||
if n := strings.TrimSpace(name); n != "" && h != nil && strings.TrimSpace(h.Get(n)) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case FingerprintSignalHeaderPrefix:
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
for k := range h {
|
||||
lk := strings.ToLower(k)
|
||||
for _, p := range s.Match {
|
||||
if np := strings.ToLower(strings.TrimSpace(p)); np != "" && strings.HasPrefix(lk, np) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
case FingerprintSignalBodyPath:
|
||||
if len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, path := range s.Match {
|
||||
if p := strings.TrimSpace(path); p != "" && gjson.GetBytes(body, p).Exists() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseEngineFingerprintSignals 解析 JSON;空串→(nil,true);非法→(nil,false)。
|
||||
func ParseEngineFingerprintSignals(raw string) ([]EngineFingerprintSignal, bool) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, true
|
||||
}
|
||||
var sigs []EngineFingerprintSignal
|
||||
if json.Unmarshal([]byte(raw), &sigs) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return sigs, true
|
||||
}
|
||||
|
||||
// ValidateEngineFingerprintSignalsJSON 校验:空=合法;非空须为合法数组,
|
||||
// 每条 type 合法且 match 至少一个非空项。供管理端写入校验复用。
|
||||
func ValidateEngineFingerprintSignalsJSON(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var sigs []EngineFingerprintSignal
|
||||
if err := json.Unmarshal([]byte(trimmed), &sigs); err != nil {
|
||||
return fmt.Errorf("must be empty or a valid JSON array of {type, match[], required}")
|
||||
}
|
||||
for i, s := range sigs {
|
||||
switch s.Type {
|
||||
case FingerprintSignalHeaderExact, FingerprintSignalHeaderPrefix, FingerprintSignalBodyPath:
|
||||
default:
|
||||
return fmt.Errorf("entry %d: type must be one of header_exact/header_prefix/body_path", i)
|
||||
}
|
||||
hasMatch := false
|
||||
for _, m := range s.Match {
|
||||
if strings.TrimSpace(m) != "" {
|
||||
hasMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMatch {
|
||||
return fmt.Errorf("entry %d: match must contain at least one non-empty value", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultEngineFingerprintSignalsJSON 默认种子的 JSON 字符串。
|
||||
func DefaultEngineFingerprintSignalsJSON() string {
|
||||
b, _ := json.Marshal(DefaultEngineFingerprintSignals)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// hdr 构造一个 http.Header(键值对)。
|
||||
func hdr(kv ...string) http.Header {
|
||||
h := http.Header{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
h.Set(kv[i], kv[i+1])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func TestEvaluateEngineFingerprint_DefaultSeed(t *testing.T) {
|
||||
sigs := DefaultEngineFingerprintSignals // 仅 x-codex- 前缀 Required
|
||||
cases := []struct {
|
||||
name string
|
||||
h http.Header
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"R1 真CLI 带x-codex-window-id", hdr("x-codex-window-id", "a1", "session-id", "u1"), ``, true},
|
||||
{"R2 纯伪装 无指纹", hdr("user-agent", "codex/1"), ``, false},
|
||||
{"R3 仅body有", hdr(), `{"client_metadata":{"x-codex-window-id":"c3"}}`, false},
|
||||
{"R4 旧版 仅session_id无x-codex-", hdr("session_id", "u4"), ``, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, EvaluateEngineFingerprint(tc.h, []byte(tc.body), sigs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateEngineFingerprint_Rules(t *testing.T) {
|
||||
exactSession := EngineFingerprintSignal{Type: FingerprintSignalHeaderExact, Match: []string{"session-id", "session_id"}, Required: true}
|
||||
prefixCodex := EngineFingerprintSignal{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: true}
|
||||
bodyWin := EngineFingerprintSignal{Type: FingerprintSignalBodyPath, Match: []string{"client_metadata.x-codex-window-id"}, Required: true}
|
||||
|
||||
t.Run("行内变体OR: 配置session-id 命中下划线session_id", func(t *testing.T) {
|
||||
require.True(t, EvaluateEngineFingerprint(hdr("session_id", "x"), nil, []EngineFingerprintSignal{exactSession}))
|
||||
})
|
||||
t.Run("跨条AND: 勾x-codex-与session 缺一即拒", func(t *testing.T) {
|
||||
both := []EngineFingerprintSignal{prefixCodex, exactSession}
|
||||
require.True(t, EvaluateEngineFingerprint(hdr("x-codex-window-id", "a", "session-id", "b"), nil, both))
|
||||
require.False(t, EvaluateEngineFingerprint(hdr("session-id", "b"), nil, both)) // 缺 x-codex-
|
||||
})
|
||||
t.Run("body_path 命中/ body空", func(t *testing.T) {
|
||||
require.True(t, EvaluateEngineFingerprint(hdr(), []byte(`{"client_metadata":{"x-codex-window-id":"1"}}`), []EngineFingerprintSignal{bodyWin}))
|
||||
require.False(t, EvaluateEngineFingerprint(hdr(), nil, []EngineFingerprintSignal{bodyWin}))
|
||||
})
|
||||
t.Run("无任何Required → true", func(t *testing.T) {
|
||||
none := []EngineFingerprintSignal{{Type: FingerprintSignalHeaderPrefix, Match: []string{"x-codex-"}, Required: false}}
|
||||
require.True(t, EvaluateEngineFingerprint(hdr(), nil, none))
|
||||
require.True(t, EvaluateEngineFingerprint(hdr(), nil, nil))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseAndValidateEngineFingerprintSignals(t *testing.T) {
|
||||
t.Run("空串=合法空", func(t *testing.T) {
|
||||
sigs, ok := ParseEngineFingerprintSignals("")
|
||||
require.True(t, ok)
|
||||
require.Nil(t, sigs)
|
||||
require.NoError(t, ValidateEngineFingerprintSignalsJSON(""))
|
||||
})
|
||||
t.Run("合法数组", func(t *testing.T) {
|
||||
raw := `[{"type":"header_prefix","match":["x-codex-"],"required":true}]`
|
||||
sigs, ok := ParseEngineFingerprintSignals(raw)
|
||||
require.True(t, ok)
|
||||
require.Len(t, sigs, 1)
|
||||
require.NoError(t, ValidateEngineFingerprintSignalsJSON(raw))
|
||||
})
|
||||
t.Run("非法JSON", func(t *testing.T) {
|
||||
_, ok := ParseEngineFingerprintSignals("not json")
|
||||
require.False(t, ok)
|
||||
require.Error(t, ValidateEngineFingerprintSignalsJSON("not json"))
|
||||
})
|
||||
t.Run("非法type 被校验拒绝", func(t *testing.T) {
|
||||
require.Error(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"bogus","match":["x"]}]`))
|
||||
})
|
||||
t.Run("match全空 被校验拒绝", func(t *testing.T) {
|
||||
require.Error(t, ValidateEngineFingerprintSignalsJSON(`[{"type":"header_exact","match":["",""]}]`))
|
||||
})
|
||||
t.Run("默认种子JSON 可解析且只勾x-codex-", func(t *testing.T) {
|
||||
sigs, ok := ParseEngineFingerprintSignals(DefaultEngineFingerprintSignalsJSON())
|
||||
require.True(t, ok)
|
||||
requiredTypes := []string{}
|
||||
for _, s := range sigs {
|
||||
if s.Required {
|
||||
requiredTypes = append(requiredTypes, s.Type+":"+s.Match[0])
|
||||
}
|
||||
}
|
||||
require.Equal(t, []string{"header_prefix:x-codex-"}, requiredTypes)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
You are Codex, a coding agent based on GPT-5. You and the user share one workspace, and your job is to collaborate with them until their goal is genuinely handled.
|
||||
|
||||
# Personality
|
||||
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.
|
||||
|
||||
## Values
|
||||
You are guided by these core values:
|
||||
- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
|
||||
- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
|
||||
- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
|
||||
|
||||
## Interaction Style
|
||||
You communicate respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps.
|
||||
|
||||
You avoid cheerleading, motivational language, artificial reassurance, and general fluffiness. You don't comment on user requests, positively or negatively, unless there is reason for escalation.
|
||||
|
||||
## Escalation
|
||||
You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
|
||||
|
||||
|
||||
# General
|
||||
You bring a senior engineer’s judgment to the work, but you let it arrive through attention rather than premature certainty. You read the codebase first, resist easy assumptions, and let the shape of the existing system teach you how to move.
|
||||
|
||||
- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss.
|
||||
- You parallelize tool calls whenever you can, especially file reads such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, and `wc`. You use `multi_tool_use.parallel` for that parallelism, and only that. Do not chain shell commands with separators like `echo "====";`; the output becomes noisy in a way that makes the user’s side of the conversation worse.
|
||||
|
||||
## Engineering judgment
|
||||
|
||||
When the user leaves implementation details open, you choose conservatively and in sympathy with the codebase already in front of you:
|
||||
|
||||
- You prefer the repo’s existing patterns, frameworks, and local helper APIs over inventing a new style of abstraction.
|
||||
- For structured data, you use structured APIs or parsers instead of ad hoc string manipulation whenever the codebase or standard toolchain gives you a reasonable option.
|
||||
- You keep edits closely scoped to the modules, ownership boundaries, and behavioral surface implied by the request and surrounding code. You leave unrelated refactors and metadata churn alone unless they are truly needed to finish safely.
|
||||
- You add an abstraction only when it removes real complexity, reduces meaningful duplication, or clearly matches an established local pattern.
|
||||
- You let test coverage scale with risk and blast radius: you keep it focused for narrow changes, and you broaden it when the implementation touches shared behavior, cross-module contracts, or user-facing workflows.
|
||||
|
||||
## Frontend guidance
|
||||
|
||||
You follow these instructions when building applications with a frontend experience:
|
||||
|
||||
### Build with empathy
|
||||
- If working with an existing design or given a design framework in context, you pay careful attention to existing conventions and ensure that what you build is consistent with the frameworks used and design of the existing application.
|
||||
- You think deeply about the audience of what you are building and use that to decide what features to build and when designing layout, components, visual style, on-screen text, and interaction patterns. Using your application should feel rich and sophisticated.
|
||||
- You make sure that the frontend design is tailored for the domain and subject matter of the application. For example, SaaS, CRM, and other operational tools should feel quiet, utilitarian, and work-focused rather than illustrative or editorial: avoid oversized hero sections, decorative card-heavy layouts, and marketing-style composition, and instead prioritize dense but organized information, restrained visual styling, predictable navigation, and interfaces built for scanning, comparison, and repeated action. A game can be more illustrative, expressive, animated, and playful.
|
||||
- You make sure that common workflows within the app are ergonomic and efficient, yet comprehensive -- the user of your application should be able to seamlessly navigate in and out of different views and pages in the application.
|
||||
|
||||
### Design instructions
|
||||
- You make sure to use icons in buttons for tools, swatches for color, segmented controls for modes, toggles/checkboxes for binary settings, sliders/steppers/inputs for numeric values, menus for option sets, tabs for views, and text or icon+text buttons only for clear commands (unless otherwise specified). Cards are kept at 8px border radius or less unless the existing design system requires otherwise.
|
||||
- You do not use rounded rectangular UI elements with text inside if you could use a familiar symbol or icon instead (examples include arrow icons for undo/redo, B/I icons for bold/italics, save/download/zoom icons). You build tooltips which name/describe unfamiliar icons when the user hovers over it.
|
||||
- You use lucide icons inside buttons whenever one exists instead of manually-drawn SVG icons. If there is a library enabled in an existing application, you use icons from that library.
|
||||
- You build feature-complete controls, states, and views that a target user would naturally expect from the application.
|
||||
- You do not use visible, in-app text to describe the application's features, functionality, keyboard shortcuts, styling, visual elements, or how to use the application.
|
||||
- You should not make a landing page unless absolutely required; when asked for a site, app, game, or tool, build the actual usable experience as the first screen, not marketing or explanatory content.
|
||||
- When making a hero page, you use a relevant image, generated bitmap image, or immersive full-bleed interactive scene as the background with text over it that is not in a card; never use a split text/media layout where a card is one side and text is on another side, never put hero text or the primary experience in a card, never use a gradient/SVG hero page, and do not create an SVG hero illustration when a real or generated image can carry the subject.
|
||||
- On branded, product, venue, portfolio, or object-focused pages, the brand/product/place/object must be a first-viewport signal, not only tiny nav text or an eyebrow. Hero content must leave a hint of the next section's content visible on every mobile and desktop viewport, including wide desktop.
|
||||
- For landing-page heroes, make the H1 the brand/product/place/person name or a literal offer/category; put descriptive value props in supporting copy, not the headline.
|
||||
- Websites and games must use visual assets. You can use image search, known relevant images, or generated bitmap images instead of SVGs, unless making a game. Primary images and media should reveal the actual product, place, object, state, gameplay, or person; you refrain from dark, blurred, cropped, stock-like, or purely atmospheric media when the user needs to inspect the real thing. For highly specific game assets you use custom SVG/Three.js/etc.
|
||||
- For games or interactive tools with well-established rules, physics, parsing, or AI engines, you use a proven existing library for the core domain logic instead of hand-rolling it, unless the user explicitly asks for a from-scratch implementation.
|
||||
- You use Three.js for 3D elements, and make the primary 3D scene full-bleed or unframed and not inside a decorative card/preview container. Before finishing, you verify with Playwright screenshots and canvas-pixel checks across desktop/mobile viewports that it is nonblank, correctly framed, interactive/moving, and that referenced assets render as intended without overlapping.
|
||||
- You do not put UI cards inside other cards. Do not style page sections as floating cards. Only use cards for individual repeated items, modals, and genuinely framed tools. Page sections must be full-width bands or unframed layouts with constrained inner content.
|
||||
- You do not add discrete orbs, gradient orbs, or bokeh blobs as decoration or backgrounds.
|
||||
- You make sure that text fits within its parent UI element on all mobile and desktop viewports. Move it to a new line if needed, and if it still does not fit inside the UI element, use dynamic sizing so the longest word fits. Text must also not occlude preceding or subsequent content. Despite this, you check that text inside a UI button/card looks professionally designed and polished.
|
||||
- Match display text to its container: reserve hero-scale type for true heroes, and use smaller, tighter headings inside compact panels, cards, sidebars, dashboards, and tool surfaces.
|
||||
- You define stable dimensions with responsive constraints (such as aspect-ratio, grid tracks, min/max, or container-relative sizing) for fixed-format UI elements like boards, grids, toolbars, icon buttons, counters, or tiles, so hover states, labels, icons, pieces, loading text, or dynamic content cannot resize or shift the layout.
|
||||
- You do not scale font size with viewport width. Letter spacing must be 0, not negative.
|
||||
- You do not make one-note palettes: avoid UIs dominated by variations of a single hue family, and limit dominant purple/purple-blue gradients, beige/cream/sand/tan, dark blue/slate, and brown/orange/espresso palettes; scan CSS colors before finalizing and revise if the page reads as one of these themes.
|
||||
- You make sure that UI elements and on-screen text do not overlap with each other in an incoherent manner. This is extremely important as it leads to a jarring user experience.
|
||||
|
||||
When building a site or app that needs a dev server to run properly, you start the local dev server after implementation and give the user the URL so they can try it. If there's already a server on that port, you use another one. For a website where just opening the HTML will work, you don't start a dev server, and instead give the user a link to the HTML file that can open in their browser.
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- You default to ASCII when editing or creating files. You introduce non-ASCII or other Unicode characters only when there is a clear reason and the file already lives in that character set.
|
||||
- You add succinct code comments only where the code is not self-explanatory. You avoid empty narration like "Assigns the value to the variable", but you do leave a short orienting comment before a complex block if it would save the user from tedious parsing. You use that tool sparingly.
|
||||
- Use `apply_patch` for manual code edits. Do not create or edit files with `cat` or other shell write tricks. Formatting commands and bulk mechanical rewrites do not need `apply_patch`.
|
||||
- Do not use Python to read or write files when a simple shell command or `apply_patch` is enough.
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, you don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, you just ignore them and don't revert them.
|
||||
- While working, you may encounter changes you did not make. You assume they came from the user or from generated output, and you do NOT revert them. If they are unrelated to your task, you ignore them. If they affect your task, you work **with** them instead of undoing them. Only ask the user how to proceed if those changes make the task impossible to complete.
|
||||
- Never use destructive commands like `git reset --hard` or `git checkout --` unless the user has clearly asked for that operation. If the request is ambiguous, ask for approval first.
|
||||
- You are clumsy in the git interactive console. Prefer non-interactive git commands whenever you can.
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request that can be answered directly by a terminal command, such as asking for the time via `date`, you go ahead and do that.
|
||||
- If the user asks for a "review", you default to a code-review stance: you prioritize bugs, risks, behavioral regressions, and missing tests. Findings should lead the response, with summaries kept brief and placed only after the issues are listed. Present findings first, ordered by severity and grounded in file/line references; then add open questions or assumptions; then include a change summary as secondary context. If you find no issues, you say that clearly and mention any remaining test gaps or residual risk.
|
||||
|
||||
## Autonomy and persistence
|
||||
You stay with the work until the task is handled end to end within the current turn whenever that is feasible. Do not stop at analysis or half-finished fixes. Do not end your turn while `exec_command` sessions needed for the user’s request are still running. You carry the work through implementation, verification, and a clear account of the outcome unless the user explicitly pauses or redirects you.
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming possible approaches, or otherwise makes clear that they do not want code changes yet, you assume they want you to make the change or run the tools needed to solve the problem. In those cases, do not stop at a proposal; implement the fix. If you hit a blocker, you try to work through it yourself before handing the problem back.
|
||||
|
||||
# Working with the user
|
||||
|
||||
You have two channels for staying in conversation with the user:
|
||||
- You share updates in `commentary` channel.
|
||||
- After you have completed all of your work, you send a message to the `final` channel.
|
||||
|
||||
The user may send messages while you are working. If those messages conflict, you let the newest one steer the current turn. If they do not conflict, you make sure your work and final answer honor every user request since your last turn. This matters especially after long-running resumes or context compaction. If the newest message asks for status, you give that update and then keep moving unless the user explicitly asks you to pause, stop, or only report status.
|
||||
|
||||
Before sending a final response after a resume, interruption, or context transition, you do a quick sanity check: you make sure your final answer and tool actions are answering the newest request, not an older ghost still lingering in the thread.
|
||||
|
||||
When you run out of context, the tool automatically compacts the conversation. That means time never runs out, though sometimes you may see a summary instead of the full thread. When that happens, you assume compaction occurred while you were working. Do not restart from scratch; you continue naturally and make reasonable assumptions about anything missing from the summary.
|
||||
|
||||
## Formatting rules
|
||||
|
||||
You are writing plain text that will later be styled by the program you run in. Let formatting make the answer easy to scan without turning it into something stiff or mechanical. Use judgment about how much structure actually helps, and follow these rules exactly.
|
||||
|
||||
- You may format with GitHub-flavored Markdown.
|
||||
- You add structure only when the task calls for it. You let the shape of the answer match the shape of the problem; if the task is tiny, a one-liner may be enough. Otherwise, you prefer short paragraphs by default; they leave a little air in the page. You order sections from general to specific to supporting detail.
|
||||
- Avoid nested bullets unless the user explicitly asks for them. Keep lists flat. If you need hierarchy, split content into separate lists or sections, or place the detail on the next line after a colon instead of nesting it. For numbered lists, use only the `1. 2. 3.` style, never `1)`. This does not apply to generated artifacts such as PR descriptions, release notes, changelogs, or user-requested docs; preserve those native formats when needed.
|
||||
- Headers are optional; you use them only when they genuinely help. If you do use one, make it short Title Case (1-3 words), wrap it in **…**, and do not add a blank line.
|
||||
- You use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
|
||||
- When referencing a real local file, prefer a clickable markdown link.
|
||||
* Clickable file links should look like [app.py](/abs/path/app.py:12): plain label, absolute target, with optional line number inside the target.
|
||||
* If a file path has spaces, wrap the target in angle brackets: [My Report.md](</abs/path/My Project/My Report.md:3>).
|
||||
* Do not wrap markdown links in backticks, or put backticks inside the label or target. This confuses the markdown renderer.
|
||||
* Do not use URIs like file://, vscode://, or https:// for file links.
|
||||
* Do not provide ranges of lines.
|
||||
* Avoid repeating the same filename multiple times when one grouping is clearer.
|
||||
- Don’t use emojis or em dashes unless explicitly instructed.
|
||||
|
||||
## Final answer instructions
|
||||
|
||||
In your final answer, you keep the light on the things that matter most. Avoid long-winded explanation. In casual conversation, you just talk like a person. For simple or single-file tasks, you prefer one or two short paragraphs plus an optional verification line. Do not default to bullets. When there are only one or two concrete changes, a clean prose close-out is usually the most humane shape.
|
||||
|
||||
- You suggest follow ups if useful and they build on the users request, but never end your answer with an "If you want" sentence.
|
||||
- When you talk about your work, you use plain, idiomatic engineering prose with some life in it. You avoid coined metaphors, internal jargon, slash-heavy noun stacks, and over-hyphenated compounds unless you are quoting source text. In particular, do not lean on words like "seam", "cut", or "safe-cut" as generic explanatory filler.
|
||||
- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
- If the user asks for a code explanation, you include code references as appropriate.
|
||||
- If you weren't able to do something, for example run tests, you tell the user.
|
||||
- Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.
|
||||
- Tone of your final answer must match your personality.
|
||||
- Never talk about goblins, gremlins, raccoons, trolls, ogres, pigeons, or other animals or creatures unless it is absolutely and unambiguously relevant to the user's query.
|
||||
|
||||
## Intermediary updates
|
||||
|
||||
- Intermediary updates go to the `commentary` channel.
|
||||
- User updates are short updates while you are working, they are NOT final answers.
|
||||
- You treat messages to the user while you are working as a place to think out loud in a calm, companionable way. You casually explain what you are doing and why in one or two sentences.
|
||||
- Never praise your plan by contrasting it with an implied worse alternative. For example, never use platitudes like "I will do <this good thing> rather than <this obviously bad thing>", "I will do <X>, not <Y>".
|
||||
- Never talk about goblins, gremlins, raccoons, trolls, ogres, pigeons, or other animals or creatures unless it is absolutely and unambiguously relevant to the user's query.
|
||||
- You provide user updates frequently, every 30s.
|
||||
- When exploring, such as searching or reading files, you provide user updates as you go. You explain what context you are gathering and what you are learning. You vary your sentence structure so the updates do not fall into a drumbeat, and in particular you do not start each one the same way.
|
||||
- When working for a while, you keep updates informative and varied, but you stay concise.
|
||||
- Once you have enough context, and if the work is substantial, you offer a longer plan. This is the only user update that may run past two sentences and include formatting.
|
||||
- If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end.
|
||||
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
|
||||
- Tone of your updates must match your personality.
|
||||
@@ -24,10 +24,14 @@ func TestCodexBaseInstructionsForModel(t *testing.T) {
|
||||
{"gpt-5.3-codex-spark", "You are Codex, based on GPT-5"},
|
||||
{"gpt-5.1-codex-max", "You are Codex, based on GPT-5"},
|
||||
{"gpt-5.2-codex", "You are Codex, based on GPT-5"},
|
||||
{"gpt-5.5", "You are Codex, a coding agent based on GPT-5"},
|
||||
{"gpt-5.2", "You are GPT-5.2 running in the Codex CLI"},
|
||||
{"gpt-5.1", "You are GPT-5.1 running in the Codex CLI"},
|
||||
{"gpt-5", "You are GPT-5.1 running in the Codex CLI"},
|
||||
{"", "You are Codex, based on GPT-5"}, // 回退
|
||||
{"gpt-5", "You are Codex, a coding agent based on GPT-5"}, // 回退到最新(GPT-5.5)
|
||||
{"gpt-5.4", "You are Codex, a coding agent based on GPT-5"}, // 未单独维护 → 最新
|
||||
{"gpt-5.3", "You are Codex, a coding agent based on GPT-5"}, // 未单独维护 → 最新
|
||||
{"some-unknown-model", "You are Codex, a coding agent based on GPT-5"},
|
||||
{"", "You are Codex, a coding agent based on GPT-5"}, // 回退到最新
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := strings.TrimSpace(CodexBaseInstructionsForModel(c.model))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package openai
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
|
||||
// Examples: "codex_vscode/1.0.0", "codex_cli_rs/0.1.2"
|
||||
@@ -9,25 +12,49 @@ var CodexCLIUserAgentPrefixes = []string{
|
||||
"codex_cli_rs/",
|
||||
}
|
||||
|
||||
// CodexOfficialClientUserAgentPrefixes matches Codex 官方客户端家族 User-Agent 前缀。
|
||||
// 该列表仅用于 OpenAI OAuth `codex_cli_only` 访问限制判定。
|
||||
var CodexOfficialClientUserAgentPrefixes = []string{
|
||||
// codexOfficialClientUAPrefixes:Codex 官方客户端家族 User-Agent 前缀(均含下划线/连字符,
|
||||
// 每项都是确定字面量;不含会被 TrimSpace 退化成裸 "codex" 的空格前缀)。
|
||||
// 用途:OpenAI OAuth `codex_cli_only` 访问限制判定 + passthrough 的「非官方 UA 安全兜底」
|
||||
// (IsCodexOfficialClientRequest 命中即视为官方真实 UA,逐字透传、不改写)。
|
||||
//
|
||||
// Cursor/VSCode 扩展两种 UA:默认 `codex_vscode/`、GitHub Copilot 集成模式 `codex_vscode_copilot/`
|
||||
// (取证 extension.js `IS="codex_vscode_copilot"` 经 env 注入);交互式 TUI 自报 `codex-tui/`
|
||||
// (连字符,2026-06-23 审计抽样约占真实流量 35%,必须显式列出)。`Codex Desktop/` 等 `Codex `
|
||||
// 前缀家族由 codexOfficialClientFamilyPrefix 单独处理(保留空格,避免退化为裸 codex 的宽松兜底)。
|
||||
var codexOfficialClientUAPrefixes = []string{
|
||||
"codex_cli_rs/",
|
||||
"codex-tui/",
|
||||
"codex_vscode/",
|
||||
"codex_vscode_copilot/",
|
||||
"codex_app/",
|
||||
"codex_chatgpt_desktop/",
|
||||
"codex_atlas/",
|
||||
"codex_exec/",
|
||||
"codex_sdk_ts/",
|
||||
"codex ",
|
||||
}
|
||||
|
||||
// CodexOfficialClientOriginatorPrefixes matches Codex 官方客户端家族 originator 前缀。
|
||||
// 说明:OpenAI 官方 Codex 客户端并不只使用固定的 codex_app 标识。
|
||||
// 例如 codex_cli_rs、codex_vscode、codex_chatgpt_desktop、codex_atlas、codex_exec、codex_sdk_ts 等。
|
||||
var CodexOfficialClientOriginatorPrefixes = []string{
|
||||
"codex_",
|
||||
"codex ",
|
||||
// codexOfficialClientFamilyPrefix 覆盖 `Codex ` 前缀家族(Codex Desktop 等),对应 codex-rs
|
||||
// is_first_party_originator 的 starts_with("Codex ")。**保留尾随空格**,并以 HasPrefix 直接比对
|
||||
// 已归一化(小写 + 去首尾空格)的值——绝不能再经 normalizeCodexClientHeader 处理本前缀,否则
|
||||
// 空格被 TrimSpace 去掉、退化成裸 "codex" 而把任何含 codex 的串都放行。
|
||||
const codexOfficialClientFamilyPrefix = "codex "
|
||||
|
||||
// codexOfficialClientOriginators:Codex 官方客户端家族 originator 精确集合。
|
||||
// app-server `initialize` 把 originator 设为 clientInfo.name 逐字值(codex-rs default_client.rs),
|
||||
// 故官方集合是这些确定字面量;镜像 is_first_party_originator / is_first_party_chat_originator
|
||||
// 并叠加 sub2api 已取证变体。用精确匹配而非「含 codex_/codex」的宽松兜底,避免 evil-codex_ 之类
|
||||
// 伪造绕过(gate 仍需 UA 双因子佐证)。新官方/合作客户端经 allowed_client.go 命名预设放行,
|
||||
// 或在 bump context/codex 时同步补入本集合。
|
||||
var codexOfficialClientOriginators = map[string]bool{
|
||||
"codex_cli_rs": true, // CLI 默认 DEFAULT_ORIGINATOR
|
||||
"codex-tui": true, // 交互式 TUI(连字符,真实流量占比最高)
|
||||
"codex_vscode": true, // VSCode/Cursor 扩展
|
||||
"codex_vscode_copilot": true, // 扩展 GitHub Copilot 集成模式
|
||||
"codex_app": true, // 历史保留
|
||||
"codex_chatgpt_desktop": true, // is_first_party_chat_originator
|
||||
"codex_atlas": true, // is_first_party_chat_originator
|
||||
"codex_exec": true, // codex exec 非交互
|
||||
"codex_sdk_ts": true, // TypeScript SDK
|
||||
}
|
||||
|
||||
// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。
|
||||
@@ -51,22 +78,85 @@ func IsCodexCLIRequest(userAgent string) bool {
|
||||
}
|
||||
|
||||
// IsCodexOfficialClientRequest checks if the User-Agent indicates a Codex 官方客户端请求。
|
||||
// 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。
|
||||
// 与 IsCodexCLIRequest 解耦,避免影响历史兼容逻辑。宽松版:官方 UA 前缀集允许 Contains 子串兜底,
|
||||
// 供 passthrough(IsCodexOfficialClientByHeaders)等历史路径使用,行为不变。
|
||||
func IsCodexOfficialClientRequest(userAgent string) bool {
|
||||
return isCodexOfficialClientRequest(userAgent, false)
|
||||
}
|
||||
|
||||
// IsCodexOfficialClientRequestStrict 同 IsCodexOfficialClientRequest,但官方 UA 前缀集只做前缀
|
||||
// 匹配(HasPrefix),不退化为 Contains 子串兜底——专供 codex_cli_only 访问门,收窄「浏览器前缀 +
|
||||
// 中段 codex token」之类的伪造面。`Codex ` 家族前缀与 UA 尾部兜底保持一致;passthrough 仍用宽松版。
|
||||
func IsCodexOfficialClientRequestStrict(userAgent string) bool {
|
||||
return isCodexOfficialClientRequest(userAgent, true)
|
||||
}
|
||||
|
||||
// isCodexOfficialClientRequest 匹配层级(优先级由高到低):
|
||||
// 1. UA 前缀集 codexOfficialClientUAPrefixes(strict=仅 HasPrefix;否则含 Contains 子串兜底)
|
||||
// 2. `Codex ` 家族前缀(保留空格,避免退化为裸 codex)
|
||||
// 3. UA 尾部兜底:codex-rs 把 clientInfo.name 写入 UA 末尾括号组 `(name; version)`。
|
||||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 只改前缀,不改尾部——可借此恢复被 override 的真实 client。
|
||||
// 生产审计(10GB / 23 天)显示,originator=cccc 的真实 codex-tui 占全 openai 流量 5.3%,
|
||||
// 若无此兜底则全部误拒。非官方尾部(如 evil/bash)仍被精确集拒绝。
|
||||
func isCodexOfficialClientRequest(userAgent string, strict bool) bool {
|
||||
ua := normalizeCodexClientHeader(userAgent)
|
||||
if ua == "" {
|
||||
return false
|
||||
}
|
||||
return matchCodexClientHeaderPrefixes(ua, CodexOfficialClientUserAgentPrefixes)
|
||||
if strict {
|
||||
if matchCodexClientHeaderStrictPrefixes(ua, codexOfficialClientUAPrefixes) {
|
||||
return true
|
||||
}
|
||||
} else if matchCodexClientHeaderPrefixes(ua, codexOfficialClientUAPrefixes) {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(ua, codexOfficialClientFamilyPrefix) {
|
||||
return true
|
||||
}
|
||||
// UA 尾部兜底:提取最后一个括号组里的 name 段,用官方 originator 检测器判定。
|
||||
if name := codexUATrailerName(ua); name != "" {
|
||||
return IsCodexOfficialClientOriginator(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// codexUATrailerName extracts the clientInfo.name from the last parenthesized group
|
||||
// of a codex-rs formatted User-Agent: `{orig}/{ver} ({os}; {arch}) {term} ({name}; {ver})`.
|
||||
//
|
||||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE 修改 UA 前缀(originator 段),但不修改尾部的
|
||||
// `(name; version)` 括号组——该组由 codex-rs engine 写入,保留真实 clientInfo.name。
|
||||
// 故从尾部提取 name 可以恢复被 override 的真实客户端标识(例如 cccc → codex-tui)。
|
||||
//
|
||||
// input 应为已归一化(小写 + 去首尾空格)的 UA。
|
||||
// 若无法解析则返回空字符串。
|
||||
func codexUATrailerName(ua string) string {
|
||||
last := strings.LastIndex(ua, "(")
|
||||
if last < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := ua[last+1:]
|
||||
closeIdx := strings.Index(rest, ")")
|
||||
if closeIdx < 0 {
|
||||
return ""
|
||||
}
|
||||
inner := strings.TrimSpace(rest[:closeIdx])
|
||||
if semi := strings.Index(inner, ";"); semi >= 0 {
|
||||
inner = strings.TrimSpace(inner[:semi])
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// IsCodexOfficialClientOriginator checks if originator indicates a Codex 官方客户端请求。
|
||||
// 精确集合匹配 + `Codex ` 家族前缀;不再用「含 codex」宽松兜底(避免伪造绕过)。
|
||||
func IsCodexOfficialClientOriginator(originator string) bool {
|
||||
v := normalizeCodexClientHeader(originator)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
return matchCodexClientHeaderPrefixes(v, CodexOfficialClientOriginatorPrefixes)
|
||||
if codexOfficialClientOriginators[v] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(v, codexOfficialClientFamilyPrefix)
|
||||
}
|
||||
|
||||
// IsCodexOfficialClientByHeaders checks whether the request headers indicate an
|
||||
@@ -92,3 +182,42 @@ func matchCodexClientHeaderPrefixes(value string, prefixes []string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchCodexClientHeaderStrictPrefixes 仅前缀匹配(HasPrefix),不含 matchCodexClientHeaderPrefixes
|
||||
// 的 Contains 子串兜底。用于 codex_cli_only 官方门收窄伪造面;passthrough 历史路径仍用宽松版。
|
||||
// value 应为已归一化(小写 + 去首尾空格)的值。
|
||||
func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if p := normalizeCodexClientHeader(prefix); p != "" && strings.HasPrefix(value, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
|
||||
var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
|
||||
|
||||
// ParseCodexEngineVersion 从 codex-rs 形态 UA 取引擎版本:
|
||||
// `{originator}/{X.Y.Z} (...)`,第一个 '/' 后、首个空格或 '(' 前的三段版本。
|
||||
// 该版本是 codex-rs CARGO_PKG_VERSION(引擎版本,CLI/app-server 一致)。
|
||||
func ParseCodexEngineVersion(ua string) (string, bool) {
|
||||
ua = strings.TrimSpace(ua)
|
||||
slash := strings.IndexByte(ua, '/')
|
||||
if slash < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := ua[slash+1:]
|
||||
end := len(rest)
|
||||
for i := 0; i < len(rest); i++ {
|
||||
if rest[i] == ' ' || rest[i] == '(' {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m := codexEngineVersionPattern.FindString(strings.TrimSpace(rest[:end]))
|
||||
if m == "" {
|
||||
return "", false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
@@ -27,6 +27,35 @@ func TestIsCodexCLIRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexUATrailerName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
// 典型 cccc override 场景:前缀改写但尾部保留真实 clientInfo.name
|
||||
{name: "cccc override → codex-tui", ua: "cccc/0.141.0 (mac os 14.6.1; arm64) apple_terminal/453 (codex-tui; 0.141.0)", want: "codex-tui"},
|
||||
{name: "cccc override Ubuntu", ua: "cccc/0.139.0 (ubuntu 22.4.0; x86_64) screen (codex-tui; 0.139.0)", want: "codex-tui"},
|
||||
// 官方客户端自报名称
|
||||
{name: "Codex Desktop 自报(小写后)", ua: "codex desktop/0.142.0 (mac os 26.0.1; arm64) unknown (codex desktop; 26.616.71553)", want: "codex desktop"},
|
||||
{name: "codex-tui 自报", ua: "codex-tui/0.141.0 (mac os 15.5.0; arm64) ghostty/1.3.1 (codex-tui; 0.141.0)", want: "codex-tui"},
|
||||
{name: "codex_exec 自报", ua: "codex_exec/0.141.0 (mac os 14.7.3; arm64) apple_terminal (codex_exec; 0.141.0)", want: "codex_exec"},
|
||||
// 无括号/无尾部组
|
||||
{name: "curl 无括号", ua: "curl/8.0.1", want: ""},
|
||||
{name: "空字符串", ua: "", want: ""},
|
||||
// 非 codex 尾部
|
||||
{name: "非 codex 尾部不影响", ua: "evil/0.1.0 (linux; x86_64) bash (evil; 0.1.0)", want: "evil"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := codexUATrailerName(tt.ua)
|
||||
if got != tt.want {
|
||||
t.Fatalf("codexUATrailerName(%q) = %q, want %q", tt.ua, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodexOfficialClientRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -35,14 +64,23 @@ func TestIsCodexOfficialClientRequest(t *testing.T) {
|
||||
}{
|
||||
{name: "codex_cli_rs 前缀", ua: "codex_cli_rs/0.98.0", want: true},
|
||||
{name: "codex_vscode 前缀", ua: "codex_vscode/1.0.0", want: true},
|
||||
{name: "codex_vscode_copilot 变体前缀", ua: "codex_vscode_copilot/0.140.0", want: true},
|
||||
{name: "codex_app 前缀", ua: "codex_app/0.1.0", want: true},
|
||||
{name: "codex_chatgpt_desktop 前缀", ua: "codex_chatgpt_desktop/1.0.0", want: true},
|
||||
{name: "codex_atlas 前缀", ua: "codex_atlas/1.0.0", want: true},
|
||||
{name: "codex_exec 前缀", ua: "codex_exec/0.1.0", want: true},
|
||||
{name: "codex_sdk_ts 前缀", ua: "codex_sdk_ts/0.1.0", want: true},
|
||||
{name: "Codex 桌面 UA", ua: "Codex Desktop/1.2.3", want: true},
|
||||
{name: "codex-tui 连字符前缀(真实流量占比最高)", ua: "codex-tui/0.141.0 (Mac OS 15.5.0; arm64) ghostty/1.3.1 (codex-tui; 0.141.0)", want: true},
|
||||
{name: "复合 UA 包含 codex_app", ua: "Mozilla/5.0 codex_app/0.1.0", want: true},
|
||||
{name: "大小写混合", ua: "Codex_VSCode/1.2.3", want: true},
|
||||
// UA 尾部兜底:cccc 是生产中 CODEX_INTERNAL_ORIGINATOR_OVERRIDE=cccc 的真实 codex-tui。
|
||||
// 审计 10GB/23天 中占非 codex 的 80.9%(494/611)、全 openai 流量的 5.3%——若不兜底会误杀。
|
||||
{name: "cccc override Mac → 尾部兜底放行", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", want: true},
|
||||
{name: "cccc override Ubuntu → 尾部兜底放行", ua: "cccc/0.139.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.139.0)", want: true},
|
||||
{name: "cccc override iTerm → 尾部兜底放行", ua: "cccc/0.137.0 (Mac OS 26.1.0; arm64) iTerm.app/3.4.22 (codex-tui; 0.137.0)", want: true},
|
||||
// 非 codex 尾部不应放行
|
||||
{name: "完全伪造尾部应拒", ua: "evil/0.1.0 (Linux; x86_64) bash (evil; 0.1.0)", want: false},
|
||||
{name: "非 codex", ua: "curl/8.0.1", want: false},
|
||||
{name: "空字符串", ua: "", want: false},
|
||||
}
|
||||
@@ -71,7 +109,10 @@ func TestIsCodexOfficialClientOriginator(t *testing.T) {
|
||||
{name: "codex_exec", originator: "codex_exec", want: true},
|
||||
{name: "codex_sdk_ts", originator: "codex_sdk_ts", want: true},
|
||||
{name: "Codex 前缀", originator: "Codex Desktop", want: true},
|
||||
{name: "codex-tui 连字符(真实流量占比最高)", originator: "codex-tui", want: true},
|
||||
{name: "空白包裹", originator: " codex_vscode ", want: true},
|
||||
{name: "伪造含 codex_ 子串应拒(L2 收紧)", originator: "evil-codex_cli", want: false},
|
||||
{name: "codex_ 混入中段应拒(L2 收紧)", originator: "my_codex_thing", want: false},
|
||||
{name: "非 codex", originator: "my_client", want: false},
|
||||
{name: "空字符串", originator: "", want: false},
|
||||
}
|
||||
@@ -86,6 +127,36 @@ func TestIsCodexOfficialClientOriginator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodexOfficialClientRequestStrict(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
want bool
|
||||
}{
|
||||
// 前缀开头:与 lax 版一致放行
|
||||
{name: "codex_cli_rs 前缀开头", ua: "codex_cli_rs/0.141.0 (x)", want: true},
|
||||
{name: "codex_vscode 前缀开头", ua: "codex_vscode/1.0.0", want: true},
|
||||
{name: "codex_app 前缀开头", ua: "codex_app/2.1.0", want: true},
|
||||
{name: "Codex 家族前缀保留", ua: "Codex Desktop/1.2.3", want: true},
|
||||
{name: "大小写混合前缀开头", ua: "Codex_CLI_Rs/0.141.0", want: true},
|
||||
// UA 尾部兜底保留:cccc override 真实 codex-tui 仍放行
|
||||
{name: "cccc override 尾部兜底仍放行", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", want: true},
|
||||
// N1 收紧:codex token 不在行首(子串)不再算官方——lax 版会因 Contains 误判 true
|
||||
{name: "浏览器前缀+中段 codex_app 收紧→拒", ua: "Mozilla/5.0 codex_app/0.141.0", want: false},
|
||||
{name: "中段 codex_cli_rs 收紧→拒", ua: "evilclient/1.0 codex_cli_rs/0.141.0", want: false},
|
||||
{name: "非 codex", ua: "curl/8.0.1", want: false},
|
||||
{name: "空字符串", ua: "", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsCodexOfficialClientRequestStrict(tt.ua)
|
||||
if got != tt.want {
|
||||
t.Fatalf("IsCodexOfficialClientRequestStrict(%q) = %v, want %v", tt.ua, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodexOfficialClientByHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -96,6 +167,10 @@ func TestIsCodexOfficialClientByHeaders(t *testing.T) {
|
||||
{name: "仅 originator 命中 desktop", originator: "Codex Desktop", want: true},
|
||||
{name: "仅 originator 命中 vscode", originator: "codex_vscode", want: true},
|
||||
{name: "仅 ua 命中 desktop", ua: "Codex Desktop/1.2.3", want: true},
|
||||
{name: "仅 originator 命中 codex-tui", originator: "codex-tui", want: true},
|
||||
{name: "仅 ua 命中 codex-tui", ua: "codex-tui/0.141.0 (Mac OS 15.5.0; arm64) ghostty/1.3.1", want: true},
|
||||
// cccc:originator 不命中精确集,但 UA 尾部兜底恢复真实 codex-tui
|
||||
{name: "cccc override → UA 尾部兜底放行(审计 5.3% 误杀场景)", ua: "cccc/0.141.0 (Mac OS 14.6.1; arm64) Apple_Terminal/453 (codex-tui; 0.141.0)", originator: "cccc", want: true},
|
||||
{name: "ua 与 originator 都未命中", ua: "curl/8.0.1", originator: "my_client", want: false},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseCodexEngineVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ua string
|
||||
wantVer string
|
||||
wantOK bool
|
||||
}{
|
||||
{"cli", "codex_cli_rs/0.141.0 (Ubuntu 22.4.0; x86_64) xterm", "0.141.0", true},
|
||||
{"tui trailer", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)", "0.140.2", true},
|
||||
{"cccc override prefix", "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", "0.142.0", true},
|
||||
{"desktop space prefix", "Codex Desktop/0.139.0 (Mac OS X 14; arm64) unknown", "0.139.0", true},
|
||||
{"alpha suffix keeps xyz", "codex_cli_rs/0.143.0-alpha.2 (Ubuntu; x86_64) x", "0.143.0", true},
|
||||
{"no slash", "curl 8.0", "", false},
|
||||
{"non numeric", "codex_cli_rs/abc (x)", "", false},
|
||||
{"empty", "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ver, ok := ParseCodexEngineVersion(tc.ua)
|
||||
require.Equal(t, tc.wantOK, ok)
|
||||
require.Equal(t, tc.wantVer, ver)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package xai
|
||||
|
||||
// Model describes an xAI model in OpenAI-compatible /models shape.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
|
||||
var defaultModels = []Model{
|
||||
{ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
|
||||
{ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
|
||||
{ID: "grok-4.20-0309-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"},
|
||||
{ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"},
|
||||
{ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"},
|
||||
}
|
||||
|
||||
func DefaultModels() []Model {
|
||||
out := make([]Model, len(defaultModels))
|
||||
copy(out, defaultModels)
|
||||
return out
|
||||
}
|
||||
|
||||
func DefaultModelIDs() []string {
|
||||
models := DefaultModels()
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func DefaultModelMapping() map[string]string {
|
||||
mapping := make(map[string]string, len(defaultModels)+3)
|
||||
for _, model := range defaultModels {
|
||||
mapping[model.ID] = model.ID
|
||||
}
|
||||
mapping["grok"] = "grok-4.3"
|
||||
mapping["grok-latest"] = "grok-4.3"
|
||||
mapping["grok-build"] = "grok-build-0.1"
|
||||
mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning"
|
||||
mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning"
|
||||
return mapping
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
)
|
||||
|
||||
const (
|
||||
OAuthIssuer = "https://auth.x.ai"
|
||||
DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration"
|
||||
DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize"
|
||||
DefaultTokenURL = OAuthIssuer + "/oauth2/token"
|
||||
DefaultBaseURL = "https://api.x.ai/v1"
|
||||
DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1"
|
||||
DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
DefaultScope = "openid profile email offline_access grok-cli:access api:access"
|
||||
DefaultRedirectURI = "http://127.0.0.1:56121/callback"
|
||||
SessionTTL = 30 * time.Minute
|
||||
|
||||
EnvAuthorizeURL = "XAI_OAUTH_AUTHORIZE_URL"
|
||||
EnvTokenURL = "XAI_OAUTH_TOKEN_URL"
|
||||
EnvClientID = "XAI_OAUTH_CLIENT_ID"
|
||||
EnvScope = "XAI_OAUTH_SCOPE"
|
||||
EnvRedirectURI = "XAI_OAUTH_REDIRECT_URI"
|
||||
EnvBaseURL = "XAI_BASE_URL"
|
||||
EnvAllowUnsafeURLOverrides = "XAI_ALLOW_UNSAFE_URL_OVERRIDES"
|
||||
EnvUnsafeAllowHighConcurrency = "XAI_GROK_UNSAFE_ALLOW_CONCURRENCY_GT_ONE"
|
||||
)
|
||||
|
||||
var (
|
||||
oauthEndpointAllowedHosts = []string{"x.ai", "*.x.ai"}
|
||||
baseURLAllowedHosts = []string{"api.x.ai", "cli-chat-proxy.grok.com"}
|
||||
)
|
||||
|
||||
// OAuthSession stores one PKCE OAuth flow.
|
||||
type OAuthSession struct {
|
||||
State string `json:"state"`
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProxyURL string `json:"proxy_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SessionStore manages xAI OAuth sessions in memory.
|
||||
type SessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*OAuthSession
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewSessionStore() *SessionStore {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*OAuthSession),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go store.cleanup()
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionID] = session
|
||||
}
|
||||
|
||||
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
return nil, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (s *SessionStore) Delete(sessionID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, sessionID)
|
||||
}
|
||||
|
||||
func (s *SessionStore) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
for id, session := range s.sessions {
|
||||
if time.Since(session.CreatedAt) > SessionTTL {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func EffectiveAuthorizeURL() string {
|
||||
return envOrDefault(EnvAuthorizeURL, DefaultAuthorizeURL)
|
||||
}
|
||||
|
||||
func ValidatedAuthorizeURL() (string, error) {
|
||||
return ValidateOAuthEndpointURL(EffectiveAuthorizeURL())
|
||||
}
|
||||
|
||||
func EffectiveTokenURL() string {
|
||||
return envOrDefault(EnvTokenURL, DefaultTokenURL)
|
||||
}
|
||||
|
||||
func ValidatedTokenURL() (string, error) {
|
||||
return ValidateOAuthEndpointURL(EffectiveTokenURL())
|
||||
}
|
||||
|
||||
func EffectiveClientID() string {
|
||||
return envOrDefault(EnvClientID, DefaultClientID)
|
||||
}
|
||||
|
||||
func EffectiveScope() string {
|
||||
return envOrDefault(EnvScope, DefaultScope)
|
||||
}
|
||||
|
||||
func EffectiveRedirectURI(override string) string {
|
||||
if trimmed := strings.TrimSpace(override); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return envOrDefault(EnvRedirectURI, DefaultRedirectURI)
|
||||
}
|
||||
|
||||
func EffectiveBaseURL(override string) string {
|
||||
if trimmed := strings.TrimSpace(override); trimmed != "" {
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
return strings.TrimRight(envOrDefault(EnvBaseURL, DefaultBaseURL), "/")
|
||||
}
|
||||
|
||||
func ValidatedBaseURL(override string) (string, error) {
|
||||
return ValidateBaseURL(EffectiveBaseURL(override))
|
||||
}
|
||||
|
||||
type RuntimeSanityCheck struct {
|
||||
Value string `json:"value"`
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
IsDefault bool `json:"is_default,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeSanityReport struct {
|
||||
BaseURL RuntimeSanityCheck `json:"base_url"`
|
||||
OAuthAuthorizeURL RuntimeSanityCheck `json:"oauth_authorize_url"`
|
||||
OAuthTokenURL RuntimeSanityCheck `json:"oauth_token_url"`
|
||||
OAuthRedirectURI RuntimeSanityCheck `json:"oauth_redirect_uri"`
|
||||
UnsafeURLOverrides bool `json:"unsafe_url_overrides"`
|
||||
UnsafeHighConcurrency bool `json:"unsafe_high_concurrency"`
|
||||
PublicGatewayScope string `json:"public_gateway_scope"`
|
||||
ProxyPolicy string `json:"proxy_policy"`
|
||||
}
|
||||
|
||||
func RuntimeSanity() RuntimeSanityReport {
|
||||
return RuntimeSanityReport{
|
||||
BaseURL: runtimeSanityCheck(EffectiveBaseURL(""), EnvBaseURL, ValidatedBaseURL),
|
||||
OAuthAuthorizeURL: runtimeSanityCheck(EffectiveAuthorizeURL(), EnvAuthorizeURL, func(string) (string, error) { return ValidatedAuthorizeURL() }),
|
||||
OAuthTokenURL: runtimeSanityCheck(EffectiveTokenURL(), EnvTokenURL, func(string) (string, error) { return ValidatedTokenURL() }),
|
||||
OAuthRedirectURI: runtimeSanityCheck(EffectiveRedirectURI(""), EnvRedirectURI, validateRedirectURI),
|
||||
UnsafeURLOverrides: AllowUnsafeURLOverrides(),
|
||||
UnsafeHighConcurrency: AllowUnsafeHighConcurrency(),
|
||||
PublicGatewayScope: "responses_only",
|
||||
ProxyPolicy: "account_proxy_optional; upstream URL allowlists enforced unless unsafe overrides are enabled",
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeSanityCheck(value string, envKey string, validate func(string) (string, error)) RuntimeSanityCheck {
|
||||
normalized, err := validate(value)
|
||||
check := RuntimeSanityCheck{
|
||||
Value: sanitizeRuntimeURLValue(normalized),
|
||||
Valid: err == nil,
|
||||
IsDefault: strings.TrimSpace(os.Getenv(envKey)) == "",
|
||||
}
|
||||
if err != nil {
|
||||
check.Value = sanitizeRuntimeURLValue(value)
|
||||
check.Error = sanitizeRuntimeError(err.Error(), value)
|
||||
}
|
||||
return check
|
||||
}
|
||||
|
||||
func validateRedirectURI(raw string) (string, error) {
|
||||
return urlvalidator.ValidateURLFormat(raw, true)
|
||||
}
|
||||
|
||||
func sanitizeRuntimeURLValue(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return trimmed
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func sanitizeRuntimeError(rawErr string, rawValue string) string {
|
||||
redacted := logredact.RedactText(rawErr)
|
||||
trimmedValue := strings.TrimSpace(rawValue)
|
||||
if trimmedValue == "" {
|
||||
return redacted
|
||||
}
|
||||
sanitizedValue := sanitizeRuntimeURLValue(trimmedValue)
|
||||
redacted = strings.ReplaceAll(redacted, trimmedValue, sanitizedValue)
|
||||
redacted = strings.ReplaceAll(redacted, logredact.RedactText(trimmedValue), sanitizedValue)
|
||||
return redacted
|
||||
}
|
||||
|
||||
func ValidateOAuthEndpointURL(raw string) (string, error) {
|
||||
if AllowUnsafeURLOverrides() {
|
||||
return urlvalidator.ValidateURLFormat(raw, true)
|
||||
}
|
||||
return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||
AllowedHosts: oauthEndpointAllowedHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: false,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateBaseURL(raw string) (string, error) {
|
||||
if AllowUnsafeURLOverrides() {
|
||||
return urlvalidator.ValidateURLFormat(raw, true)
|
||||
}
|
||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||
AllowedHosts: baseURLAllowedHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizeKnownBaseURLPath(normalized)
|
||||
}
|
||||
|
||||
func normalizeKnownBaseURLPath(raw string) (string, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", fmt.Errorf("invalid url: %s", raw)
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
if path == "" {
|
||||
parsed.Path = "/v1"
|
||||
parsed.RawPath = ""
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
if path != "/v1" {
|
||||
return "", fmt.Errorf("base URL path must be /v1")
|
||||
}
|
||||
parsed.Path = path
|
||||
parsed.RawPath = ""
|
||||
return strings.TrimRight(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func AllowUnsafeURLOverrides() bool {
|
||||
return envBool(EnvAllowUnsafeURLOverrides)
|
||||
}
|
||||
|
||||
func AllowUnsafeHighConcurrency() bool {
|
||||
return envBool(EnvUnsafeAllowHighConcurrency)
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func GenerateState() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateSessionID() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
bytes, err := GenerateRandomBytes(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64URLEncode(bytes), nil
|
||||
}
|
||||
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64URLEncode(hash[:])
|
||||
}
|
||||
|
||||
func base64URLEncode(data []byte) string {
|
||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||
}
|
||||
|
||||
func BuildAuthorizationURL(state, codeChallenge, redirectURI, nonce string) (string, error) {
|
||||
redirectURI = EffectiveRedirectURI(redirectURI)
|
||||
authorizeURL, err := ValidatedAuthorizeURL()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid authorize url: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("response_type", "code")
|
||||
params.Set("client_id", EffectiveClientID())
|
||||
params.Set("redirect_uri", redirectURI)
|
||||
params.Set("scope", EffectiveScope())
|
||||
params.Set("state", state)
|
||||
params.Set("nonce", nonce)
|
||||
params.Set("code_challenge", codeChallenge)
|
||||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("plan", "generic")
|
||||
params.Set("referrer", "sub2api")
|
||||
|
||||
return fmt.Sprintf("%s?%s", authorizeURL, params.Encode()), nil
|
||||
}
|
||||
|
||||
// AuthorizationInput is a parsed manual OAuth callback input.
|
||||
type AuthorizationInput struct {
|
||||
Code string
|
||||
State string
|
||||
RequiresState bool
|
||||
}
|
||||
|
||||
// ParseAuthorizationInput accepts a full callback URL, query string, or bare code.
|
||||
func ParseAuthorizationInput(raw string) AuthorizationInput {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return AuthorizationInput{}
|
||||
}
|
||||
|
||||
if parsed, err := url.Parse(trimmed); err == nil && parsed != nil {
|
||||
values := parsed.Query()
|
||||
if code := strings.TrimSpace(values.Get("code")); code != "" {
|
||||
return AuthorizationInput{
|
||||
Code: code,
|
||||
State: strings.TrimSpace(values.Get("state")),
|
||||
RequiresState: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queryCandidate := strings.TrimPrefix(trimmed, "?")
|
||||
if strings.Contains(queryCandidate, "=") {
|
||||
if values, err := url.ParseQuery(queryCandidate); err == nil {
|
||||
if code := strings.TrimSpace(values.Get("code")); code != "" {
|
||||
return AuthorizationInput{
|
||||
Code: code,
|
||||
State: strings.TrimSpace(values.Get("state")),
|
||||
RequiresState: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthorizationInput{Code: trimmed}
|
||||
}
|
||||
|
||||
func BuildResponsesURL(baseURL string) (string, error) {
|
||||
validatedBaseURL, err := ValidatedBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base url: %w", err)
|
||||
}
|
||||
return validatedBaseURL + "/responses", nil
|
||||
}
|
||||
|
||||
func BuildChatCompletionsURL(baseURL string) (string, error) {
|
||||
validatedBaseURL, err := ValidatedBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base url: %w", err)
|
||||
}
|
||||
return validatedBaseURL + "/chat/completions", nil
|
||||
}
|
||||
|
||||
// TokenResponse represents xAI OAuth token responses.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int64 `json:"expires_in,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//go:build unit
|
||||
|
||||
package xai
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseAuthorizationInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantCode string
|
||||
wantState string
|
||||
wantRequiresState bool
|
||||
}{
|
||||
{
|
||||
name: "full callback url",
|
||||
raw: "http://127.0.0.1:56121/callback?code=abc123&state=state456",
|
||||
wantCode: "abc123",
|
||||
wantState: "state456",
|
||||
wantRequiresState: true,
|
||||
},
|
||||
{
|
||||
name: "query string",
|
||||
raw: "?code=abc123&state=state456",
|
||||
wantCode: "abc123",
|
||||
wantState: "state456",
|
||||
wantRequiresState: true,
|
||||
},
|
||||
{
|
||||
name: "full callback url missing state",
|
||||
raw: "http://127.0.0.1:56121/callback?code=abc123",
|
||||
wantCode: "abc123",
|
||||
wantRequiresState: true,
|
||||
},
|
||||
{
|
||||
name: "query string missing state",
|
||||
raw: "code=abc123",
|
||||
wantCode: "abc123",
|
||||
wantRequiresState: true,
|
||||
},
|
||||
{
|
||||
name: "bare code",
|
||||
raw: "abc123",
|
||||
wantCode: "abc123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ParseAuthorizationInput(tt.raw)
|
||||
require.Equal(t, tt.wantCode, got.Code)
|
||||
require.Equal(t, tt.wantState, got.State)
|
||||
require.Equal(t, tt.wantRequiresState, got.RequiresState)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLIncludesHermesCompatibleParameters(t *testing.T) {
|
||||
t.Setenv(EnvAuthorizeURL, "https://auth.example.test/oauth2/authorize")
|
||||
t.Setenv(EnvClientID, "client-id")
|
||||
t.Setenv(EnvScope, "openid profile offline_access api:access")
|
||||
t.Setenv(EnvAllowUnsafeURLOverrides, "true")
|
||||
|
||||
authURL, err := BuildAuthorizationURL("state", "challenge", "http://127.0.0.1:56121/callback", "nonce")
|
||||
require.NoError(t, err)
|
||||
parsed, err := url.Parse(authURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
values := parsed.Query()
|
||||
require.Equal(t, "https", parsed.Scheme)
|
||||
require.Equal(t, "auth.example.test", parsed.Host)
|
||||
require.Equal(t, "/oauth2/authorize", parsed.Path)
|
||||
require.Equal(t, "code", values.Get("response_type"))
|
||||
require.Equal(t, "client-id", values.Get("client_id"))
|
||||
require.Equal(t, "http://127.0.0.1:56121/callback", values.Get("redirect_uri"))
|
||||
require.Equal(t, "openid profile offline_access api:access", values.Get("scope"))
|
||||
require.Equal(t, "state", values.Get("state"))
|
||||
require.Equal(t, "nonce", values.Get("nonce"))
|
||||
require.Equal(t, "challenge", values.Get("code_challenge"))
|
||||
require.Equal(t, "S256", values.Get("code_challenge_method"))
|
||||
require.Equal(t, "generic", values.Get("plan"))
|
||||
require.Equal(t, "sub2api", values.Get("referrer"))
|
||||
}
|
||||
|
||||
func TestValidateXAIURLsAllowOfficialOAuthAndGatewayHosts(t *testing.T) {
|
||||
authorizeURL, err := ValidateOAuthEndpointURL(DefaultAuthorizeURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultAuthorizeURL, authorizeURL)
|
||||
|
||||
tokenURL, err := ValidateOAuthEndpointURL(DefaultTokenURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultTokenURL, tokenURL)
|
||||
|
||||
baseURL, err := ValidateBaseURL(DefaultBaseURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultBaseURL, baseURL)
|
||||
|
||||
cliBaseURL, err := ValidateBaseURL(DefaultCLIBaseURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultCLIBaseURL, cliBaseURL)
|
||||
|
||||
baseURLNoPath, err := ValidateBaseURL("https://api.x.ai")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultBaseURL, baseURLNoPath)
|
||||
|
||||
chatURL, err := BuildChatCompletionsURL(DefaultCLIBaseURL + "/")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultCLIBaseURL+"/chat/completions", chatURL)
|
||||
}
|
||||
|
||||
func TestValidateXAIURLsRejectArbitraryHostsByDefault(t *testing.T) {
|
||||
_, err := ValidateOAuthEndpointURL("https://auth.example.test/oauth2/token")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = ValidateBaseURL("https://xai.test/v1")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = ValidateBaseURL("http://127.0.0.1:8080/v1")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = ValidateBaseURL("https://api.x.ai/custom")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidateXAIURLsAllowUnsafeDevOverride(t *testing.T) {
|
||||
t.Setenv(EnvAllowUnsafeURLOverrides, "true")
|
||||
|
||||
tokenURL, err := ValidateOAuthEndpointURL("http://127.0.0.1:8080/oauth2/token")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://127.0.0.1:8080/oauth2/token", tokenURL)
|
||||
|
||||
baseURL, err := ValidateBaseURL("http://127.0.0.1:8080/v1/")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "http://127.0.0.1:8080/v1", baseURL)
|
||||
}
|
||||
|
||||
func TestRuntimeSanityReportsSafeDefaults(t *testing.T) {
|
||||
t.Setenv(EnvBaseURL, "")
|
||||
t.Setenv(EnvAuthorizeURL, "")
|
||||
t.Setenv(EnvTokenURL, "")
|
||||
t.Setenv(EnvRedirectURI, "")
|
||||
t.Setenv(EnvAllowUnsafeURLOverrides, "")
|
||||
t.Setenv(EnvUnsafeAllowHighConcurrency, "")
|
||||
|
||||
report := RuntimeSanity()
|
||||
require.True(t, report.BaseURL.Valid)
|
||||
require.Equal(t, DefaultBaseURL, report.BaseURL.Value)
|
||||
require.True(t, report.BaseURL.IsDefault)
|
||||
require.True(t, report.OAuthAuthorizeURL.Valid)
|
||||
require.True(t, report.OAuthTokenURL.Valid)
|
||||
require.True(t, report.OAuthRedirectURI.Valid)
|
||||
require.False(t, report.UnsafeURLOverrides)
|
||||
require.False(t, report.UnsafeHighConcurrency)
|
||||
require.Equal(t, "responses_only", report.PublicGatewayScope)
|
||||
require.Contains(t, report.ProxyPolicy, "account_proxy_optional")
|
||||
}
|
||||
|
||||
func TestRuntimeSanityReportsInvalidOverridesWithoutSecrets(t *testing.T) {
|
||||
t.Setenv(EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
|
||||
t.Setenv(EnvAuthorizeURL, "https://auth.example.test/oauth2/authorize")
|
||||
t.Setenv(EnvTokenURL, "https://auth.example.test/oauth2/token")
|
||||
t.Setenv(EnvRedirectURI, "not a url")
|
||||
t.Setenv(EnvClientID, "client-secret-like-value")
|
||||
t.Setenv(EnvAllowUnsafeURLOverrides, "")
|
||||
|
||||
report := RuntimeSanity()
|
||||
require.False(t, report.BaseURL.Valid)
|
||||
require.False(t, report.BaseURL.IsDefault)
|
||||
require.Contains(t, report.BaseURL.Error, "invalid url")
|
||||
require.NotContains(t, report.BaseURL.Value, "secret")
|
||||
require.False(t, report.OAuthAuthorizeURL.Valid)
|
||||
require.False(t, report.OAuthTokenURL.Valid)
|
||||
require.False(t, report.OAuthRedirectURI.Valid)
|
||||
require.NotContains(t, report.ProxyPolicy, "client-secret-like-value")
|
||||
}
|
||||
|
||||
func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mapping := DefaultModelMapping()
|
||||
require.Equal(t, "grok-4.3", mapping["grok"])
|
||||
require.Equal(t, "grok-4.3", mapping["grok-latest"])
|
||||
require.Equal(t, "grok-build-0.1", mapping["grok-build"])
|
||||
require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"])
|
||||
require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"])
|
||||
require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"])
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package xai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type QuotaWindow struct {
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
Remaining *int64 `json:"remaining,omitempty"`
|
||||
ResetUnix *int64 `json:"reset_unix,omitempty"`
|
||||
ResetAt string `json:"reset_at,omitempty"`
|
||||
}
|
||||
|
||||
type QuotaSnapshot struct {
|
||||
Requests *QuotaWindow `json:"requests,omitempty"`
|
||||
Tokens *QuotaWindow `json:"tokens,omitempty"`
|
||||
RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"`
|
||||
SubscriptionTier string `json:"subscription_tier,omitempty"`
|
||||
EntitlementStatus string `json:"entitlement_status,omitempty"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
HeadersObserved bool `json:"headers_observed"`
|
||||
ObservationSource string `json:"observation_source,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
LastHeadersSeenAt string `json:"last_headers_seen_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *QuotaSnapshot) HasObservedHeaders() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return s.HeadersObserved ||
|
||||
s.Requests != nil ||
|
||||
s.Tokens != nil ||
|
||||
s.RetryAfterSeconds != nil ||
|
||||
s.SubscriptionTier != "" ||
|
||||
s.EntitlementStatus != "" ||
|
||||
len(s.Headers) > 0
|
||||
}
|
||||
|
||||
var quotaHeaderAllowlist = []string{
|
||||
"x-ratelimit-limit-requests",
|
||||
"x-ratelimit-remaining-requests",
|
||||
"x-ratelimit-reset-requests",
|
||||
"x-ratelimit-limit-tokens",
|
||||
"x-ratelimit-remaining-tokens",
|
||||
"x-ratelimit-reset-tokens",
|
||||
"retry-after",
|
||||
"x-subscription-tier",
|
||||
"xai-subscription-tier",
|
||||
"x-entitlement-status",
|
||||
"xai-entitlement-status",
|
||||
}
|
||||
|
||||
func ParseQuotaHeaders(headers http.Header, statusCode int) *QuotaSnapshot {
|
||||
return parseQuotaHeaders(headers, statusCode, "", false)
|
||||
}
|
||||
|
||||
func ObserveQuotaHeaders(headers http.Header, statusCode int, source string) *QuotaSnapshot {
|
||||
return parseQuotaHeaders(headers, statusCode, source, true)
|
||||
}
|
||||
|
||||
func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepEmpty bool) *QuotaSnapshot {
|
||||
if headers == nil && !keepEmpty {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
snapshot := &QuotaSnapshot{
|
||||
Requests: parseQuotaWindow(headers, "requests"),
|
||||
Tokens: parseQuotaWindow(headers, "tokens"),
|
||||
StatusCode: statusCode,
|
||||
Headers: make(map[string]string),
|
||||
ObservationSource: strings.TrimSpace(source),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if snapshot.ObservationSource == "active_probe" {
|
||||
snapshot.LastProbeAt = now
|
||||
}
|
||||
if retryAfter := parseRetryAfter(headers.Get("retry-after")); retryAfter != nil {
|
||||
snapshot.RetryAfterSeconds = retryAfter
|
||||
}
|
||||
snapshot.SubscriptionTier = firstHeader(headers, "xai-subscription-tier", "x-subscription-tier")
|
||||
snapshot.EntitlementStatus = firstHeader(headers, "xai-entitlement-status", "x-entitlement-status")
|
||||
|
||||
for _, name := range quotaHeaderAllowlist {
|
||||
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||
snapshot.Headers[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
if snapshot.Requests == nil &&
|
||||
snapshot.Tokens == nil &&
|
||||
snapshot.RetryAfterSeconds == nil &&
|
||||
snapshot.SubscriptionTier == "" &&
|
||||
snapshot.EntitlementStatus == "" &&
|
||||
len(snapshot.Headers) == 0 {
|
||||
if keepEmpty {
|
||||
return snapshot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
snapshot.HeadersObserved = true
|
||||
snapshot.LastHeadersSeenAt = now
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func parseQuotaWindow(headers http.Header, dimension string) *QuotaWindow {
|
||||
window := &QuotaWindow{
|
||||
Limit: parseInt64Ptr(headers.Get("x-ratelimit-limit-" + dimension)),
|
||||
Remaining: parseInt64Ptr(headers.Get("x-ratelimit-remaining-" + dimension)),
|
||||
}
|
||||
if reset := parseResetHeader(headers.Get("x-ratelimit-reset-" + dimension)); reset != nil {
|
||||
window.ResetUnix = reset
|
||||
window.ResetAt = time.Unix(*reset, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
if window.Limit == nil && window.Remaining == nil && window.ResetUnix == nil {
|
||||
return nil
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
func parseResetHeader(raw string) *int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
if value > 1_000_000_000_000 {
|
||||
value = value / 1000
|
||||
}
|
||||
return &value
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
value := t.Unix()
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRetryAfter(raw string) *int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if value, err := strconv.Atoi(raw); err == nil {
|
||||
return &value
|
||||
}
|
||||
if t, err := http.ParseTime(raw); err == nil {
|
||||
seconds := int(time.Until(t).Seconds())
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
return &seconds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInt64Ptr(raw string) *int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func firstHeader(headers http.Header, names ...string) string {
|
||||
for _, name := range names {
|
||||
if value := strings.TrimSpace(headers.Get(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build unit
|
||||
|
||||
package xai
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseQuotaHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("x-ratelimit-limit-requests", "100")
|
||||
headers.Set("x-ratelimit-remaining-requests", "25")
|
||||
headers.Set("x-ratelimit-reset-requests", "1893456000")
|
||||
headers.Set("x-ratelimit-limit-tokens", "1000000")
|
||||
headers.Set("x-ratelimit-remaining-tokens", "750000")
|
||||
headers.Set("retry-after", "60")
|
||||
headers.Set("xai-subscription-tier", "supergrok")
|
||||
headers.Set("xai-entitlement-status", "active")
|
||||
headers.Set("authorization", "should-not-be-copied")
|
||||
|
||||
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
|
||||
require.NotNil(t, snapshot)
|
||||
require.Equal(t, http.StatusTooManyRequests, snapshot.StatusCode)
|
||||
require.True(t, snapshot.HeadersObserved)
|
||||
require.NotEmpty(t, snapshot.LastHeadersSeenAt)
|
||||
require.Equal(t, int64(100), *snapshot.Requests.Limit)
|
||||
require.Equal(t, int64(25), *snapshot.Requests.Remaining)
|
||||
require.Equal(t, int64(1893456000), *snapshot.Requests.ResetUnix)
|
||||
require.Equal(t, "2030-01-01T00:00:00Z", snapshot.Requests.ResetAt)
|
||||
require.Equal(t, int64(1000000), *snapshot.Tokens.Limit)
|
||||
require.Equal(t, int64(750000), *snapshot.Tokens.Remaining)
|
||||
require.Equal(t, 60, *snapshot.RetryAfterSeconds)
|
||||
require.Equal(t, "supergrok", snapshot.SubscriptionTier)
|
||||
require.Equal(t, "active", snapshot.EntitlementStatus)
|
||||
require.Contains(t, snapshot.Headers, "x-ratelimit-limit-requests")
|
||||
require.NotContains(t, snapshot.Headers, "authorization")
|
||||
}
|
||||
|
||||
func TestParseQuotaHeadersReturnsNilForMissingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Nil(t, ParseQuotaHeaders(http.Header{}, http.StatusOK))
|
||||
}
|
||||
|
||||
func TestObserveQuotaHeadersRecordsNoHeaderProbe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshot := ObserveQuotaHeaders(http.Header{}, http.StatusOK, "active_probe")
|
||||
require.NotNil(t, snapshot)
|
||||
require.False(t, snapshot.HeadersObserved)
|
||||
require.Equal(t, http.StatusOK, snapshot.StatusCode)
|
||||
require.Equal(t, "active_probe", snapshot.ObservationSource)
|
||||
require.NotEmpty(t, snapshot.LastProbeAt)
|
||||
require.Empty(t, snapshot.LastHeadersSeenAt)
|
||||
require.Empty(t, snapshot.Headers)
|
||||
require.Nil(t, snapshot.Requests)
|
||||
require.Nil(t, snapshot.Tokens)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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/Wei-Shaw/sub2api/internal/util/logredact"
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
type grokOAuthClient struct {
|
||||
tokenURL string
|
||||
}
|
||||
|
||||
func NewGrokOAuthClient() service.GrokOAuthClient {
|
||||
return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()}
|
||||
}
|
||||
|
||||
func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) {
|
||||
client, err := createGrokReqClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
|
||||
}
|
||||
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
clientID = xai.EffectiveClientID()
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", xai.EffectiveRedirectURI(redirectURI))
|
||||
formData.Set("code_verifier", codeVerifier)
|
||||
|
||||
var tokenResp xai.TokenResponse
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_EXCHANGE_FAILED", "token exchange failed", resp)
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error) {
|
||||
client, err := createGrokReqClient(proxyURL)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
|
||||
}
|
||||
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
clientID = xai.EffectiveClientID()
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "refresh_token")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("refresh_token", refreshToken)
|
||||
|
||||
var tokenResp xai.TokenResponse
|
||||
resp, err := client.R().
|
||||
SetContext(ctx).
|
||||
SetHeader("User-Agent", "sub2api-grok-oauth/1.0").
|
||||
SetFormDataFromValues(formData).
|
||||
SetSuccessResult(&tokenResp).
|
||||
Post(c.tokenURL)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_REQUEST_FAILED", "request failed: %v", err)
|
||||
}
|
||||
if !resp.IsSuccessState() {
|
||||
return nil, grokOAuthStatusError("GROK_OAUTH_TOKEN_REFRESH_FAILED", "token refresh failed", resp)
|
||||
}
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
func createGrokReqClient(proxyURL string) (*req.Client, error) {
|
||||
return getSharedReqClient(reqClientOptions{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func grokOAuthStatusError(code, message string, resp *req.Response) error {
|
||||
statusCode := http.StatusBadGateway
|
||||
errorCode := code
|
||||
upstreamStatus := 0
|
||||
if resp != nil && resp.StatusCode == http.StatusForbidden {
|
||||
statusCode = http.StatusForbidden
|
||||
errorCode = "GROK_OAUTH_ENTITLEMENT_DENIED"
|
||||
}
|
||||
body := ""
|
||||
if resp != nil {
|
||||
upstreamStatus = resp.StatusCode
|
||||
body = logredact.RedactText(resp.String())
|
||||
}
|
||||
return infraerrors.Newf(statusCode, errorCode, "%s: status %d, body: %s", message, upstreamStatus, body)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGrokOAuthClientExchangeAndRefreshUseFormFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.NoError(t, r.ParseForm())
|
||||
require.Equal(t, "client-id", r.Form.Get("client_id"))
|
||||
|
||||
switch r.Form.Get("grant_type") {
|
||||
case "authorization_code":
|
||||
require.Equal(t, "auth-code", r.Form.Get("code"))
|
||||
require.Equal(t, "http://127.0.0.1:56121/callback", r.Form.Get("redirect_uri"))
|
||||
require.Equal(t, "verifier", r.Form.Get("code_verifier"))
|
||||
require.Empty(t, r.Form.Get("code_challenge"))
|
||||
require.Empty(t, r.Form.Get("code_challenge_method"))
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "exchange-access",
|
||||
"refresh_token": "exchange-refresh",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "openid api:access",
|
||||
})
|
||||
case "refresh_token":
|
||||
require.Equal(t, "refresh-token", r.Form.Get("refresh_token"))
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "refresh-access",
|
||||
"refresh_token": "refresh-rotated",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 7200,
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unexpected grant_type", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
|
||||
exchanged, err := client.ExchangeCode(
|
||||
context.Background(),
|
||||
"auth-code",
|
||||
"verifier",
|
||||
"http://127.0.0.1:56121/callback",
|
||||
"",
|
||||
"client-id",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "exchange-access", exchanged.AccessToken)
|
||||
require.Equal(t, "exchange-refresh", exchanged.RefreshToken)
|
||||
require.Equal(t, int64(3600), exchanged.ExpiresIn)
|
||||
require.Equal(t, "openid api:access", exchanged.Scope)
|
||||
|
||||
refreshed, err := client.RefreshToken(context.Background(), "refresh-token", "", "client-id")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refresh-access", refreshed.AccessToken)
|
||||
require.Equal(t, "refresh-rotated", refreshed.RefreshToken)
|
||||
require.Equal(t, int64(7200), refreshed.ExpiresIn)
|
||||
}
|
||||
|
||||
func TestGrokOAuthClientRefreshForbiddenClassifiesEntitlement(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"subscription required"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
_, err := client.RefreshToken(context.Background(), "refresh-token", "", "client-id")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, strings.ToUpper(err.Error()), "GROK_OAUTH_ENTITLEMENT_DENIED")
|
||||
}
|
||||
|
||||
func TestGrokOAuthClientStatusErrorRedactsSensitiveResponseBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","access_token":"access-secret","refresh_token":"refresh-secret","code_verifier":"verifier-secret"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv(xai.EnvTokenURL, server.URL)
|
||||
|
||||
client := NewGrokOAuthClient()
|
||||
_, err := client.RefreshToken(context.Background(), "refresh-secret", "", "client-id")
|
||||
require.Error(t, err)
|
||||
|
||||
errText := err.Error()
|
||||
require.Contains(t, errText, "status 400")
|
||||
require.Contains(t, errText, `\"refresh_token\":\"***\"`)
|
||||
require.NotContains(t, errText, "access-secret")
|
||||
require.NotContains(t, errText, "refresh-secret")
|
||||
require.NotContains(t, errText, "verifier-secret")
|
||||
}
|
||||
@@ -19,6 +19,7 @@ func ensureSimpleModeDefaultGroups(ctx context.Context, client *dbent.Client) er
|
||||
service.PlatformOpenAI: 1,
|
||||
service.PlatformGemini: 1,
|
||||
service.PlatformAntigravity: 2,
|
||||
service.PlatformGrok: 1,
|
||||
}
|
||||
|
||||
for platform, minCount := range requiredByPlatform {
|
||||
|
||||
@@ -113,11 +113,12 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
|
||||
}
|
||||
|
||||
if cmd.BalanceCost > 0 {
|
||||
newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.NewBalance = &newBalance
|
||||
result.BalanceOverdrafted = !sufficient
|
||||
}
|
||||
|
||||
if cmd.APIKeyQuotaCost > 0 {
|
||||
@@ -173,9 +174,23 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip
|
||||
return service.ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, error) {
|
||||
func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, bool, error) {
|
||||
var newBalance float64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET balance = balance - $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL AND balance >= $1
|
||||
RETURNING balance
|
||||
`, amount, userID).Scan(&newBalance)
|
||||
if err == nil {
|
||||
return newBalance, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users
|
||||
SET balance = balance - $1,
|
||||
updated_at = NOW()
|
||||
@@ -183,12 +198,12 @@ func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, am
|
||||
RETURNING balance
|
||||
`, amount, userID).Scan(&newBalance)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, service.ErrUserNotFound
|
||||
return 0, false, service.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, false, err
|
||||
}
|
||||
return newBalance, nil
|
||||
return newBalance, false, nil
|
||||
}
|
||||
|
||||
func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
conditionalBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL AND balance >= \$1\s+RETURNING balance`
|
||||
overdraftBalanceDeductSQL = `(?s)UPDATE users\s+SET balance = balance - \$1,\s+updated_at = NOW\(\)\s+WHERE id = \$2 AND deleted_at IS NULL\s+RETURNING balance`
|
||||
)
|
||||
|
||||
func TestDeductUsageBillingBalance_UsesSufficientBalanceGuard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(2.5, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(7.5))
|
||||
mock.ExpectCommit()
|
||||
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 2.5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, sufficient)
|
||||
require.InDelta(t, 7.5, newBalance, 0.000001)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDeductUsageBillingBalance_RecordsOverdraftWhenGuardMisses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, 42, 10)
|
||||
require.NoError(t, err)
|
||||
require.False(t, sufficient)
|
||||
require.InDelta(t, -5.0, newBalance, 0.000001)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyUsageBillingEffects_FlagsBalanceOverdraft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"balance"}).AddRow(-5.0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result := &service.UsageBillingApplyResult{Applied: true}
|
||||
err = (&usageBillingRepository{}).applyUsageBillingEffects(ctx, tx, &service.UsageBillingCommand{
|
||||
UserID: 42,
|
||||
BalanceCost: 10,
|
||||
}, result)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.NewBalance)
|
||||
require.InDelta(t, -5.0, *result.NewBalance, 0.000001)
|
||||
require.True(t, result.BalanceOverdrafted)
|
||||
require.NoError(t, tx.Commit())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestDeductUsageBillingBalance_ReturnsUserNotFoundWhenNoUserUpdated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(conditionalBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery(overdraftBalanceDeductSQL).
|
||||
WithArgs(10.0, int64(42)).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectRollback()
|
||||
|
||||
_, _, err = deductUsageBillingBalance(ctx, tx, 42, 10)
|
||||
require.ErrorIs(t, err, service.ErrUserNotFound)
|
||||
require.NoError(t, tx.Rollback())
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -3545,6 +3545,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||
COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost,
|
||||
@@ -3575,6 +3577,8 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
|
||||
&stats.TotalInputTokens,
|
||||
&stats.TotalOutputTokens,
|
||||
&stats.TotalCacheTokens,
|
||||
&stats.TotalCacheCreationTokens,
|
||||
&stats.TotalCacheReadTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.TotalActualCost,
|
||||
&totalAccountCost,
|
||||
|
||||
@@ -397,11 +397,13 @@ func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T)
|
||||
"total_input_tokens",
|
||||
"total_output_tokens",
|
||||
"total_cache_tokens",
|
||||
"total_cache_creation_tokens",
|
||||
"total_cache_read_tokens",
|
||||
"total_cost",
|
||||
"total_actual_cost",
|
||||
"total_account_cost",
|
||||
"avg_duration_ms",
|
||||
}).AddRow(int64(1), int64(2), int64(3), int64(4), 1.2, 1.0, 1.2, 20.0))
|
||||
}).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0))
|
||||
mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), requestType).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"}))
|
||||
@@ -489,9 +491,10 @@ func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testin
|
||||
mock.ExpectQuery("FROM usage_logs").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"total_requests", "total_input_tokens", "total_output_tokens",
|
||||
"total_cache_tokens", "total_cost", "total_actual_cost",
|
||||
"total_cache_tokens", "total_cache_creation_tokens", "total_cache_read_tokens",
|
||||
"total_cost", "total_actual_cost",
|
||||
"total_account_cost", "avg_duration_ms",
|
||||
}).AddRow(int64(50), int64(1000), int64(2000), int64(100), 15.0, 12.5, 11.0, 100.0))
|
||||
}).AddRow(int64(50), int64(1000), int64(2000), int64(100), int64(60), int64(40), 15.0, 12.5, 11.0, 100.0))
|
||||
mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\)").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"}))
|
||||
mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\)").
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
|
||||
_, err := repo.Create(ctx, &service.UsageLog{
|
||||
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
|
||||
Model: "claude-3", InputTokens: 2, OutputTokens: 3,
|
||||
CacheCreationTokens: 4, CacheReadTokens: 5,
|
||||
TotalCost: 0.5, ActualCost: 0.4, CreatedAt: now,
|
||||
InboundEndpoint: &inboundEndpoint, UpstreamEndpoint: &upstreamEndpoint,
|
||||
})
|
||||
@@ -44,6 +45,9 @@ func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
|
||||
require.Equal(t, int64(3), stats.TotalRequests)
|
||||
require.Equal(t, int64(6), stats.TotalInputTokens)
|
||||
require.Equal(t, int64(9), stats.TotalOutputTokens)
|
||||
require.Equal(t, int64(27), stats.TotalCacheTokens)
|
||||
require.Equal(t, int64(12), stats.TotalCacheCreationTokens)
|
||||
require.Equal(t, int64(15), stats.TotalCacheReadTokens)
|
||||
require.InDelta(t, 1.2, stats.TotalActualCost, 1e-9)
|
||||
require.NotEmpty(t, stats.Endpoints)
|
||||
require.NotEmpty(t, stats.UpstreamEndpoints)
|
||||
|
||||
@@ -757,6 +757,17 @@ func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount flo
|
||||
func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount float64) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.User.Update().
|
||||
Where(dbuser.IDEQ(id), dbuser.BalanceGTE(amount)).
|
||||
AddBalance(-amount).
|
||||
Save(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
n, err = client.User.Update().
|
||||
Where(dbuser.IDEQ(id)).
|
||||
AddBalance(-amount).
|
||||
Save(ctx)
|
||||
|
||||
@@ -141,6 +141,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewClaudeOAuthClient,
|
||||
NewHTTPUpstream,
|
||||
NewOpenAIOAuthClient,
|
||||
NewGrokOAuthClient,
|
||||
NewGeminiOAuthClient,
|
||||
NewGeminiCliCodeAssistClient,
|
||||
NewGeminiDriveClient,
|
||||
|
||||
@@ -832,6 +832,12 @@ func TestAPIContracts(t *testing.T) {
|
||||
"table_page_size_options": [10, 20, 50, 100],
|
||||
"min_claude_code_version": "",
|
||||
"max_claude_code_version": "",
|
||||
"min_codex_version": "",
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
"backend_mode_enabled": false,
|
||||
"enable_cch_signing": false,
|
||||
@@ -850,7 +856,6 @@ func TestAPIContracts(t *testing.T) {
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"openai_codex_user_agent": "",
|
||||
"openai_allow_claude_code_codex_plugin": false,
|
||||
"openai_fast_policy_settings": {
|
||||
"rules": []
|
||||
},
|
||||
@@ -1086,6 +1091,12 @@ func TestAPIContracts(t *testing.T) {
|
||||
"enable_anthropic_cache_ttl_1h_injection": false,
|
||||
"rewrite_message_cache_control": false,
|
||||
"antigravity_user_agent_version": "",
|
||||
"min_codex_version": "",
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"web_search_emulation_enabled": false,
|
||||
"payment_visible_method_alipay_source": "",
|
||||
"payment_visible_method_wxpay_source": "",
|
||||
@@ -1093,7 +1104,6 @@ func TestAPIContracts(t *testing.T) {
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": false,
|
||||
"openai_codex_user_agent": "",
|
||||
"openai_allow_claude_code_codex_plugin": false,
|
||||
"openai_fast_policy_settings": {
|
||||
"rules": []
|
||||
},
|
||||
|
||||
@@ -47,6 +47,9 @@ func RegisterAdminRoutes(
|
||||
// Antigravity OAuth
|
||||
registerAntigravityOAuthRoutes(admin, h)
|
||||
|
||||
// Grok OAuth
|
||||
registerGrokOAuthRoutes(admin, h)
|
||||
|
||||
// 代理管理
|
||||
registerProxyRoutes(admin, h)
|
||||
|
||||
@@ -362,6 +365,7 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
openai.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
|
||||
openai.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
|
||||
openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
|
||||
openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT)
|
||||
openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota)
|
||||
openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota)
|
||||
}
|
||||
@@ -385,6 +389,20 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
||||
}
|
||||
}
|
||||
|
||||
func registerGrokOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
grok := admin.Group("/grok")
|
||||
{
|
||||
grok.POST("/oauth/auth-url", h.Admin.GrokOAuth.GenerateAuthURL)
|
||||
grok.POST("/oauth/exchange-code", h.Admin.GrokOAuth.ExchangeCode)
|
||||
grok.POST("/oauth/refresh-token", h.Admin.GrokOAuth.RefreshToken)
|
||||
grok.POST("/oauth/create-from-oauth", h.Admin.GrokOAuth.CreateAccountFromOAuth)
|
||||
grok.POST("/accounts/:id/refresh", h.Admin.GrokOAuth.RefreshAccountToken)
|
||||
grok.GET("/accounts/:id/quota", h.Admin.GrokOAuth.QueryQuota)
|
||||
grok.POST("/accounts/:id/reset-quota", h.Admin.GrokOAuth.ResetQuota)
|
||||
grok.GET("/runtime-sanity", h.Admin.GrokOAuth.RuntimeSanity)
|
||||
}
|
||||
}
|
||||
|
||||
func registerProxyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
proxies := admin.Group("/proxies")
|
||||
{
|
||||
|
||||
@@ -31,6 +31,27 @@ func RegisterGatewayRoutes(
|
||||
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
|
||||
requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter)
|
||||
|
||||
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
|
||||
switch getGroupPlatform(c) {
|
||||
case service.PlatformOpenAI, service.PlatformGrok:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
isOpenAIGatewayPlatform := func(c *gin.Context) bool {
|
||||
return getGroupPlatform(c) == service.PlatformOpenAI
|
||||
}
|
||||
rejectGrokUnsupportedEndpoint := func(c *gin.Context, endpoint string) {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": endpoint + " is not supported for Grok groups",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// API网关(Claude API兼容)
|
||||
gateway := r.Group("/v1")
|
||||
gateway.Use(bodyLimit)
|
||||
@@ -42,7 +63,11 @@ func RegisterGatewayRoutes(
|
||||
{
|
||||
// /v1/messages: auto-route based on group platform
|
||||
gateway.POST("/messages", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Messages API")
|
||||
return
|
||||
}
|
||||
if isOpenAIGatewayPlatform(c) {
|
||||
h.OpenAIGateway.Messages(c)
|
||||
return
|
||||
}
|
||||
@@ -50,7 +75,7 @@ func RegisterGatewayRoutes(
|
||||
})
|
||||
// /v1/messages/count_tokens: OpenAI groups get 404
|
||||
gateway.POST("/messages/count_tokens", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"type": "error",
|
||||
@@ -67,23 +92,33 @@ func RegisterGatewayRoutes(
|
||||
gateway.GET("/usage", h.Gateway.Usage)
|
||||
// OpenAI Responses API: auto-route based on group platform
|
||||
gateway.POST("/responses", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
||||
h.OpenAIGateway.Responses(c)
|
||||
return
|
||||
}
|
||||
h.Gateway.Responses(c)
|
||||
})
|
||||
gateway.POST("/responses/*subpath", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
||||
h.OpenAIGateway.Responses(c)
|
||||
return
|
||||
}
|
||||
h.Gateway.Responses(c)
|
||||
})
|
||||
gateway.GET("/responses", h.OpenAIGateway.ResponsesWebSocket)
|
||||
gateway.GET("/responses", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Responses WebSocket API")
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
})
|
||||
// OpenAI Chat Completions API: auto-route based on group platform
|
||||
gateway.POST("/chat/completions", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Chat Completions API")
|
||||
return
|
||||
}
|
||||
if isOpenAIGatewayPlatform(c) {
|
||||
h.OpenAIGateway.ChatCompletions(c)
|
||||
return
|
||||
}
|
||||
@@ -147,7 +182,7 @@ func RegisterGatewayRoutes(
|
||||
|
||||
// OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform
|
||||
responsesHandler := func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
||||
h.OpenAIGateway.Responses(c)
|
||||
return
|
||||
}
|
||||
@@ -155,17 +190,33 @@ func RegisterGatewayRoutes(
|
||||
}
|
||||
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
|
||||
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Responses WebSocket API")
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
})
|
||||
codexDirect := r.Group("/backend-api/codex")
|
||||
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic)
|
||||
{
|
||||
codexDirect.POST("/responses", responsesHandler)
|
||||
codexDirect.POST("/responses/*subpath", responsesHandler)
|
||||
codexDirect.GET("/responses", h.OpenAIGateway.ResponsesWebSocket)
|
||||
codexDirect.GET("/responses", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Responses WebSocket API")
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
})
|
||||
}
|
||||
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
|
||||
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
if getGroupPlatform(c) == service.PlatformGrok {
|
||||
rejectGrokUnsupportedEndpoint(c, "Chat Completions API")
|
||||
return
|
||||
}
|
||||
if isOpenAIGatewayPlatform(c) {
|
||||
h.OpenAIGateway.ChatCompletions(c)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,10 +14,15 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newGatewayRoutesTestRouter() *gin.Engine {
|
||||
func newGatewayRoutesTestRouter(platform ...string) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
groupPlatform := service.PlatformOpenAI
|
||||
if len(platform) > 0 && platform[0] != "" {
|
||||
groupPlatform = platform[0]
|
||||
}
|
||||
|
||||
RegisterGatewayRoutes(
|
||||
router,
|
||||
&handler.Handlers{
|
||||
@@ -28,7 +33,7 @@ func newGatewayRoutesTestRouter() *gin.Engine {
|
||||
groupID := int64(1)
|
||||
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{Platform: service.PlatformOpenAI},
|
||||
Group: &service.Group{Platform: groupPlatform},
|
||||
})
|
||||
c.Next()
|
||||
}),
|
||||
@@ -77,3 +82,40 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
|
||||
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI images handler", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRoutesGrokOnlyAllowsResponsesHTTP(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter(service.PlatformGrok)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/v1/messages"},
|
||||
{http.MethodPost, "/v1/chat/completions"},
|
||||
{http.MethodPost, "/chat/completions"},
|
||||
{http.MethodGet, "/v1/responses"},
|
||||
{http.MethodGet, "/responses"},
|
||||
{http.MethodGet, "/backend-api/codex/responses"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{"model":"grok"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, "method=%s path=%s", tc.method, tc.path)
|
||||
require.Contains(t, w.Body.String(), "not supported for Grok groups")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
"/backend-api/codex/responses",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"grok","input":"hi"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should still reach Responses handler", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
@@ -77,6 +78,21 @@ const (
|
||||
|
||||
const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
|
||||
|
||||
const (
|
||||
OpenAIAuthModePersonalAccessToken = "personalAccessToken"
|
||||
openAIAuthModeCredentialKey = "auth_mode"
|
||||
openAIAuthModeLegacyCredentialKey = "openai_auth_mode"
|
||||
)
|
||||
|
||||
func isOpenAIPersonalAccessTokenAuthMode(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "personalaccesstoken", "personal_access_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type TempUnschedulableRule struct {
|
||||
ErrorCode int `json:"error_code"`
|
||||
Keywords []string `json:"keywords"`
|
||||
@@ -175,6 +191,18 @@ func (a *Account) IsGemini() bool {
|
||||
return a.Platform == PlatformGemini
|
||||
}
|
||||
|
||||
func (a *Account) IsGrok() bool {
|
||||
return a.Platform == PlatformGrok
|
||||
}
|
||||
|
||||
func (a *Account) IsGrokOAuth() bool {
|
||||
return a.IsGrok() && a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
func (a *Account) IsOpenAICompatible() bool {
|
||||
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok)
|
||||
}
|
||||
|
||||
func (a *Account) GeminiOAuthType() string {
|
||||
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
@@ -493,6 +521,9 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
if a.Platform == domain.PlatformGrok {
|
||||
return xai.DefaultModelMapping()
|
||||
}
|
||||
// Bedrock 默认映射由 forwardBedrock 统一处理(需配合 region prefix 调整)
|
||||
return nil
|
||||
}
|
||||
@@ -501,6 +532,9 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
if a.Platform == domain.PlatformGrok {
|
||||
return xai.DefaultModelMapping()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -525,6 +559,9 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
|
||||
if a.Platform == domain.PlatformAntigravity {
|
||||
return domain.DefaultAntigravityModelMapping
|
||||
}
|
||||
if a.Platform == domain.PlatformGrok {
|
||||
return xai.DefaultModelMapping()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1060,6 +1097,14 @@ func (a *Account) IsOpenAIOAuth() bool {
|
||||
return a.IsOpenAI() && a.Type == AccountTypeOAuth
|
||||
}
|
||||
|
||||
func (a *Account) IsOpenAIPersonalAccessToken() bool {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return false
|
||||
}
|
||||
return isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeCredentialKey)) ||
|
||||
isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeLegacyCredentialKey))
|
||||
}
|
||||
|
||||
func (a *Account) IsOpenAIApiKey() bool {
|
||||
return a.IsOpenAI() && a.Type == AccountTypeAPIKey
|
||||
}
|
||||
@@ -1091,6 +1136,31 @@ func (a *Account) GetOpenAIRefreshToken() string {
|
||||
return a.GetCredential("refresh_token")
|
||||
}
|
||||
|
||||
func (a *Account) GetGrokBaseURL() string {
|
||||
if !a.IsGrok() {
|
||||
return ""
|
||||
}
|
||||
baseURL := a.GetCredential("base_url")
|
||||
if baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
return xai.DefaultBaseURL
|
||||
}
|
||||
|
||||
func (a *Account) GetGrokAccessToken() string {
|
||||
if !a.IsGrok() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("access_token")
|
||||
}
|
||||
|
||||
func (a *Account) GetGrokRefreshToken() string {
|
||||
if !a.IsGrokOAuth() {
|
||||
return ""
|
||||
}
|
||||
return a.GetCredential("refresh_token")
|
||||
}
|
||||
|
||||
func (a *Account) GetOpenAIIDToken() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
@@ -1119,6 +1189,34 @@ func (a *Account) GetChatGPTAccountID() string {
|
||||
return a.GetCredential("chatgpt_account_id")
|
||||
}
|
||||
|
||||
func (a *Account) IsChatGPTAccountFedRAMP() bool {
|
||||
if !a.IsOpenAIOAuth() || a.Credentials == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := a.Credentials["chatgpt_account_is_fedramp"]
|
||||
if !ok || v == nil {
|
||||
return false
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseBool(value.String())
|
||||
return err == nil && parsed
|
||||
case float64:
|
||||
return value != 0
|
||||
case int:
|
||||
return value != 0
|
||||
case int64:
|
||||
return value != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) GetOpenAIDeviceID() string {
|
||||
if !a.IsOpenAIOAuth() {
|
||||
return ""
|
||||
@@ -1140,9 +1238,12 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
|
||||
if capability == "" {
|
||||
return true
|
||||
}
|
||||
if !a.IsOpenAI() {
|
||||
if !a.IsOpenAICompatible() {
|
||||
return false
|
||||
}
|
||||
if a.IsGrok() {
|
||||
return capability == OpenAIEndpointCapabilityChatCompletions
|
||||
}
|
||||
switch capability {
|
||||
case OpenAIEndpointCapabilityChatCompletions:
|
||||
case OpenAIEndpointCapabilityEmbeddings:
|
||||
@@ -1208,6 +1309,9 @@ func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) {
|
||||
}
|
||||
|
||||
func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
|
||||
if capability == "" {
|
||||
return true
|
||||
}
|
||||
if !a.IsOpenAI() {
|
||||
return false
|
||||
}
|
||||
@@ -1527,36 +1631,15 @@ func (a *Account) IsCodexCLIOnlyEnabled() bool {
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// GetCodexCLIOnlyAllowedClients 返回 codex_cli_only 之上额外放行的命名客户端预设 ID 列表。
|
||||
// 仅 OpenAI OAuth 账号生效;缺失或类型不符时返回空。预设 ID 的具体匹配规则由
|
||||
// openai 包的 registry 固化,配置只能引用预设键、不能自定义规则。
|
||||
func (a *Account) GetCodexCLIOnlyAllowedClients() []string {
|
||||
if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil {
|
||||
return nil
|
||||
// IsCodexCLIOnlyAppServerAllowed 返回 codex_cli_only 账号是否额外放行 Codex app-server
|
||||
// 第三方客户端(运行时与全局 app_server 开关 OR)。字段:accounts.extra.codex_cli_only_allow_app_server。
|
||||
// 仅在 codex_cli_only 已启用时有意义;字段缺失或类型不符按 false(不放行)处理。
|
||||
func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool {
|
||||
if !a.IsCodexCLIOnlyEnabled() {
|
||||
return false
|
||||
}
|
||||
raw, ok := a.Extra["codex_cli_only_allowed_clients"]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []string:
|
||||
result := make([]string, 0, len(v))
|
||||
for _, s := range v {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return nil
|
||||
v, ok := a.Extra["codex_cli_only_allow_app_server"].(bool)
|
||||
return ok && v
|
||||
}
|
||||
|
||||
// WindowCostSchedulability 窗口费用调度状态
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccount_GetCodexCLIOnlyAllowedClients(t *testing.T) {
|
||||
t.Run("OAuth 账号读取 []any 字符串列表", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code"}},
|
||||
}
|
||||
require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("OAuth 账号读取 []string 列表", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only_allowed_clients": []string{"claude_code"}},
|
||||
}
|
||||
require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("[]string 跳过空白元素", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only_allowed_clients": []string{"claude_code", "", " "}},
|
||||
}
|
||||
require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("跳过非字符串与空白元素", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code", 123, "", " "}},
|
||||
}
|
||||
require.Equal(t, []string{"claude_code"}, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("非 OAuth 账号返回空", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"codex_cli_only_allowed_clients": []any{"claude_code"}},
|
||||
}
|
||||
require.Empty(t, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("Extra 为空返回空", func(t *testing.T) {
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
require.Empty(t, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
|
||||
t.Run("字段缺失返回空", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{},
|
||||
}
|
||||
require.Empty(t, account.GetCodexCLIOnlyAllowedClients())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccount_IsCodexCLIOnlyAppServerAllowed(t *testing.T) {
|
||||
t.Run("codex_cli_only 开 + allow_app_server=true → true", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": true},
|
||||
}
|
||||
require.True(t, account.IsCodexCLIOnlyAppServerAllowed())
|
||||
})
|
||||
|
||||
t.Run("codex_cli_only 开 + allow_app_server=false → false", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true, "codex_cli_only_allow_app_server": false},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyAppServerAllowed())
|
||||
})
|
||||
|
||||
t.Run("codex_cli_only 开 + 字段缺失 → false", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only": true},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyAppServerAllowed())
|
||||
})
|
||||
|
||||
t.Run("codex_cli_only 关 → 即便 allow_app_server=true 也 false", func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"codex_cli_only_allow_app_server": true},
|
||||
}
|
||||
require.False(t, account.IsCodexCLIOnlyAppServerAllowed())
|
||||
})
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini) {
|
||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
|
||||
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
||||
}
|
||||
}
|
||||
@@ -304,7 +304,7 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini) {
|
||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformGrok) {
|
||||
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,9 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
|
||||
case PlatformGemini:
|
||||
// TODO: 测试Gemini API凭证
|
||||
return nil
|
||||
case PlatformGrok:
|
||||
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform: %s", account.Platform)
|
||||
}
|
||||
|
||||
@@ -526,7 +526,6 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
var authToken string
|
||||
var apiURL string
|
||||
var isOAuth bool
|
||||
var chatgptAccountID string
|
||||
|
||||
if account.IsOAuth() {
|
||||
isOAuth = true
|
||||
@@ -538,7 +537,6 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
|
||||
// OAuth uses ChatGPT internal API
|
||||
apiURL = chatgptCodexAPIURL
|
||||
chatgptAccountID = account.GetChatGPTAccountID()
|
||||
} else if account.Type == "apikey" {
|
||||
// API Key - use Platform API
|
||||
authToken = account.GetOpenAIApiKey()
|
||||
@@ -590,9 +588,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
if isOAuth {
|
||||
req.Host = "chatgpt.com"
|
||||
req.Header.Set("accept", "text/event-stream")
|
||||
if chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
}
|
||||
|
||||
// Get proxy URL
|
||||
@@ -699,7 +695,6 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
|
||||
authToken := ""
|
||||
apiURL := ""
|
||||
isOAuth := false
|
||||
chatgptAccountID := ""
|
||||
|
||||
switch {
|
||||
case account.IsOAuth():
|
||||
@@ -709,7 +704,6 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
|
||||
return s.sendErrorAndEnd(c, "No access token available")
|
||||
}
|
||||
apiURL = chatgptCodexAPIURL + "/compact"
|
||||
chatgptAccountID = account.GetChatGPTAccountID()
|
||||
case account.Type == AccountTypeAPIKey:
|
||||
authToken = account.GetOpenAIApiKey()
|
||||
if authToken == "" {
|
||||
@@ -756,9 +750,7 @@ func (s *AccountTestService) testOpenAICompactConnection(c *gin.Context, account
|
||||
|
||||
if isOAuth {
|
||||
req.Host = "chatgpt.com"
|
||||
if chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
@@ -1609,9 +1601,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
} else {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
|
||||
@@ -27,8 +27,9 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
"chatgpt_account_is_fedramp": true,
|
||||
},
|
||||
}
|
||||
repo := &snapshotUpdateAccountRepo{
|
||||
@@ -60,6 +61,7 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
|
||||
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
|
||||
require.Equal(t, "chatgpt-acc", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
|
||||
require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
|
||||
updates := <-updateCalls
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
@@ -193,6 +194,17 @@ type UsageInfo struct {
|
||||
// Antigravity 多模型配额
|
||||
AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"`
|
||||
|
||||
// Grok / xAI 被动额度快照
|
||||
GrokRequestQuota *xai.QuotaWindow `json:"grok_request_quota,omitempty"`
|
||||
GrokTokenQuota *xai.QuotaWindow `json:"grok_token_quota,omitempty"`
|
||||
GrokRetryAfterSeconds *int `json:"grok_retry_after_seconds,omitempty"`
|
||||
GrokEntitlementStatus string `json:"grok_entitlement_status,omitempty"`
|
||||
GrokQuotaSnapshotState string `json:"grok_quota_snapshot_state,omitempty"`
|
||||
GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"`
|
||||
GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"`
|
||||
GrokLastStatusCode int `json:"grok_last_status_code,omitempty"`
|
||||
GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"`
|
||||
|
||||
// Antigravity 账号级信息
|
||||
SubscriptionTier string `json:"subscription_tier,omitempty"` // 归一化订阅等级: FREE/PRO/ULTRA/UNKNOWN
|
||||
SubscriptionTierRaw string `json:"subscription_tier_raw,omitempty"` // 上游原始订阅等级名称
|
||||
@@ -263,6 +275,7 @@ type AccountUsageService struct {
|
||||
usageFetcher ClaudeUsageFetcher
|
||||
geminiQuotaService *GeminiQuotaService
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher
|
||||
grokQuotaFetcher *GrokQuotaFetcher
|
||||
cache *UsageCache
|
||||
identityCache IdentityCache
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
@@ -275,6 +288,7 @@ func NewAccountUsageService(
|
||||
usageFetcher ClaudeUsageFetcher,
|
||||
geminiQuotaService *GeminiQuotaService,
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher,
|
||||
grokQuotaFetcher *GrokQuotaFetcher,
|
||||
cache *UsageCache,
|
||||
identityCache IdentityCache,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
@@ -285,6 +299,7 @@ func NewAccountUsageService(
|
||||
usageFetcher: usageFetcher,
|
||||
geminiQuotaService: geminiQuotaService,
|
||||
antigravityQuotaFetcher: antigravityQuotaFetcher,
|
||||
grokQuotaFetcher: grokQuotaFetcher,
|
||||
cache: cache,
|
||||
identityCache: identityCache,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
@@ -328,6 +343,14 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for
|
||||
return usage, err
|
||||
}
|
||||
|
||||
if account.Platform == PlatformGrok {
|
||||
usage, err := s.getGrokUsage(ctx, account)
|
||||
if err == nil {
|
||||
s.tryClearRecoverableAccountError(ctx, account)
|
||||
}
|
||||
return usage, err
|
||||
}
|
||||
|
||||
// 只有oauth类型账号可以通过API获取usage(有profile scope)
|
||||
if account.CanGetUsage() {
|
||||
var apiResp *ClaudeUsageResponse
|
||||
@@ -637,9 +660,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
|
||||
req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent))
|
||||
}
|
||||
}
|
||||
if chatgptAccountID := account.GetChatGPTAccountID(); chatgptAccountID != "" {
|
||||
req.Header.Set("chatgpt-account-id", chatgptAccountID)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
@@ -839,6 +860,30 @@ func (s *AccountUsageService) getAntigravityUsage(ctx context.Context, account *
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (s *AccountUsageService) getGrokUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
||||
if s.grokQuotaFetcher == nil {
|
||||
now := time.Now()
|
||||
return &UsageInfo{UpdatedAt: &now}, nil
|
||||
}
|
||||
usage := s.grokQuotaFetcher.BuildUsageInfo(account)
|
||||
if usage.GrokQuotaSnapshotState == "" {
|
||||
if usage.ErrorCode == "quota_unknown" {
|
||||
usage.GrokQuotaSnapshotState = "unknown_until_first_response"
|
||||
} else {
|
||||
usage.GrokQuotaSnapshotState = "observed"
|
||||
}
|
||||
}
|
||||
|
||||
if s.usageLogRepo != nil && account != nil {
|
||||
if stats, err := s.usageLogRepo.GetAccountTodayStats(ctx, account.ID); err == nil && stats != nil {
|
||||
usage.GrokLocalUsage = windowStatsFromAccountStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
enrichUsageWithAccountError(usage, account)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// recalcAntigravityRemainingSeconds 重新计算 Antigravity UsageInfo 中各窗口的 RemainingSeconds
|
||||
// 用于从缓存取出时更新倒计时,避免返回过时的剩余秒数
|
||||
func recalcAntigravityRemainingSeconds(info *UsageInfo) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeAccountConcurrencyCapsGrokOAuthUnlessUnsafe(t *testing.T) {
|
||||
t.Setenv(xai.EnvUnsafeAllowHighConcurrency, "")
|
||||
|
||||
require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 0))
|
||||
require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, -5))
|
||||
require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 50))
|
||||
require.Equal(t, 2, normalizeAccountConcurrency(PlatformOpenAI, AccountTypeOAuth, 2))
|
||||
require.Equal(t, 2, normalizeAccountConcurrency(PlatformGrok, AccountTypeAPIKey, 2))
|
||||
}
|
||||
|
||||
func TestNormalizeAccountConcurrencyAllowsGrokOAuthUnsafeOverride(t *testing.T) {
|
||||
t.Setenv(xai.EnvUnsafeAllowHighConcurrency, "true")
|
||||
|
||||
require.Equal(t, 50, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 50))
|
||||
require.Equal(t, 1, normalizeAccountConcurrency(PlatformGrok, AccountTypeOAuth, 0))
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/httputil"
|
||||
)
|
||||
|
||||
@@ -1780,6 +1781,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
@@ -1913,7 +1916,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
}
|
||||
|
||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini) && len(accountIDsToCopy) > 0 {
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
|
||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||
@@ -2208,7 +2211,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
}
|
||||
|
||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini) && len(accountIDsToCopy) > 0 {
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
|
||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||
@@ -2569,6 +2572,18 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func normalizeAccountConcurrency(platform, accountType string, concurrency int) int {
|
||||
if platform == PlatformGrok && accountType == AccountTypeOAuth {
|
||||
if concurrency <= 0 {
|
||||
return 1
|
||||
}
|
||||
if concurrency > 1 && !xai.AllowUnsafeHighConcurrency() {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return concurrency
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
|
||||
// 绑定分组
|
||||
groupIDs := input.GroupIDs
|
||||
@@ -2601,7 +2616,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
||||
Credentials: input.Credentials,
|
||||
Extra: input.Extra,
|
||||
ProxyID: input.ProxyID,
|
||||
Concurrency: input.Concurrency,
|
||||
Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency),
|
||||
Priority: input.Priority,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
@@ -2734,7 +2749,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
// 只在指针非 nil 时更新 Concurrency(支持设置为 0)
|
||||
if input.Concurrency != nil {
|
||||
account.Concurrency = *input.Concurrency
|
||||
account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
|
||||
}
|
||||
// 只在指针非 nil 时更新 Priority(支持设置为 0)
|
||||
if input.Priority != nil {
|
||||
|
||||