Compare commits

...
Author SHA1 Message Date
Wesley Liddick 86f93c28ee Merge pull request #6977 from alfadb/fix/deepseek-model-name-validation
fix(deepseek): 未配置 model_mapping 时按官方模型名单校验,不再透传未知模型名
2026-09-15 16:20:31 +08:00
Wesley Liddick 30ed40a56a Merge pull request #7074 from Brisbanehuang/codex/fix-codex-quota-windows
fix(scheduler): 按规范 5h/7d 窗口读取 Codex 配额与重置时间
2026-09-15 16:17:37 +08:00
Wesley Liddick b8275209e8 Merge pull request #7162 from HuntercodeT/fix/antigravity-gemini-sse-separator
fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行(Antigravity CLI invalid stream chunk)
2026-09-15 16:17:25 +08:00
Wesley Liddick 25c21aedda Merge pull request #7129 from kk-418/fix/grok-build-responses-sequence-number
fix(responses): 始终在 Responses SSE 中写出 sequence_number 避免反序列化失败
2026-09-15 15:28:33 +08:00
Wesley Liddick 2d9af8592f Merge pull request #7153 from hezhaoqian1/codex/upstream-api-key-provider-filter
feat: filter API key groups by provider
2026-09-15 15:26:29 +08:00
HuntercodeT 8447bdd368 fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行
handleGeminiStreamingResponse 把上游每条 data 行改写成 "data: ...\n\n",
紧接着又把上游的空分隔行原样透传成第三个 "\n",下游看到的事件之间是
"\n\n\n"。google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,
多出的 "\n" 粘到下一个事件开头,前缀变成 "\ndata" 而报
"iterateResponseStream: invalid stream chunk",每条流都在第二个事件中断。

空分隔行不再透传(data 分支已经写过 "\n\n"),附回归测试:两个事件
(LF 与 CRLF 各一)经过后输出必须恰好是一个空行分隔,并按 "\n\n" 切帧
逐个校验前缀。

Fixes #7161
2026-09-15 15:10:45 +08:00
shaw 32682a4f84 chore: update sponsors 2026-09-15 14:01:28 +08:00
hezhaoqian1 55a95d4c67 feat: filter API key groups by provider 2026-09-15 11:16:17 +08:00
alfadb a939553c87 fix(deepseek): 模型名校验不再默认兜底,未配置映射时按官方名单校验
- 未配置 model_mapping 的 DeepSeek 账号不再放行任意模型名,改为官方名单精确匹配
  (deepseek-flash / deepseek-v4-pro / deepseek-v4-flash /
   deepseek-v4-flash-vision-exp / deepseek-v4-pro-0813)
- 校验前先归一化:剥离 Claude Code 客户端上下文后缀 [1m](含重复形式)并统一小写,
  使官方 Claude Code 接入写法 deepseek-flash[1m] 继续可用
- 未知模型名在调度阶段即拒绝,返回 404 model_not_found,避免透传上游后触发
  per-(账号,模型) 冷却、并避免上游把不认识的模型名静默兜底成 deepseek-flash
- 出站前剥离泄漏的 [1m] 后缀,仅作用于 DeepSeek 平台
- 配置了 model_mapping 的账号语义不变(映射优先)
2026-09-15 10:42:29 +08:00
Wesley Liddick badfad8b72 Merge pull request #6257 from 88766lawrence/fix/oauth-promo-code-6216
fix(auth): preserve promo codes through OAuth registration
2026-09-15 09:01:39 +08:00
Wesley Liddick 1be482c313 Merge pull request #7148 from Wei-Shaw/revert-7110-codex/bug-batch-image-access-session
Revert "fix(batch-image): scope access cache to the current user"
2026-09-15 09:00:23 +08:00
cikenerdandCursor 087987070b fix(responses): 为 grok-build 始终写出 sequence_number
网关合成/重编的 Responses SSE 因 omitempty 或缺字段丢掉 sequence_number,
grok-build 会整轮反序列化失败。合成失败帧、compact 桥接和转换 wire JSON
一律写出该字段,0 也保留。

Fixes #7128

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:45:53 +08:00
Brisbanehuang db76cc4e4e fix(scheduler): 按规范窗口读取 Codex 配额与重置时间
优先读取 codex_5h/codex_7d 规范字段,旧快照复用 Normalize 的窗口分类;用量与重置时间按同源窗口配对。重置评分与管理端快照复用规范 5h 重置时间,保留会话列回退,不改变权重与暂停阈值。
2026-09-12 09:06:33 -04:00
Lawrence c0c4ce1ce6 Merge upstream main and retain blank OAuth promo code regression test 2026-09-07 14:27:55 +08:00
Lawrence e1eff57c25 fix(auth): preserve promo codes through OAuth registration 2026-08-26 21:12:52 +08:00
27 changed files with 805 additions and 93 deletions
+2 -10
View File
@@ -49,8 +49,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
</tr>
<tr>
@@ -102,14 +102,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 Thanks to FastAIToken for sponsoring this project! <a href="http://www.fastaitoken.com/register">FastAIToken</a> is an AI API aggregation platform for developers, supporting mainstream large models such as OpenAI, Claude, and Gemini. Top-up at 1:1 — 1 CNY = 1 USD of API credit — letting developers use the world's leading large model services at lower cost and with greater convenience.<br>
🚀 The platform offers a variety of channels to choose from: an ultra-low-price 0.02x OpenAI promotional group (limited time), groups as low as 0.25x OpenAI, 0.7x Claude with 95% fixed cache, and a 1.2x Claude Max channel. It also provides a public status page showing real-time availability, latency, and operating status of each group for transparent and reliable service, plus 7×24 human technical support (not bots) with fast responses to developer needs.
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
+2 -10
View File
@@ -50,8 +50,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
</tr>
<tr>
@@ -105,14 +105,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 感谢 FastAIToken 对本项目的赞助! <a href="http://www.fastaitoken.com/register">FastAIToken</a> 是面向开发者的 AI API 聚合平台,支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务。<br>
🚀 平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
+2 -10
View File
@@ -49,8 +49,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
</tr>
<tr>
@@ -104,14 +104,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 FastAIToken のご支援に感謝します!<a href="http://www.fastaitoken.com/register">FastAIToken</a> は開発者向けの AI API アグリゲーションプラットフォームで、OpenAI、Claude、Gemini などの主要な大規模モデルに対応しています。チャージは 1:1(1 元 = 1 米ドル分の API クレジット)で、開発者がより低コスト・より手軽に世界トップクラスの大規模モデルサービスを利用できます。<br>
🚀 プラットフォームでは多彩なチャネルを自由に選択できます:超低価格の 0.02x OpenAI 特典グループ(期間限定)、最低 0.25x の OpenAI グループ、0.7x Claude(95% 固定キャッシュ)、1.2x Claude Max チャネル。さらに、各グループの可用率・レイテンシ・稼働状況をリアルタイムで表示する公開ステータスページを提供し、透明で信頼できるサービスを実現。7×24 時間の有人テクニカルサポート(ボットではありません)により、開発者のニーズに迅速に対応します。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
@@ -33,10 +33,13 @@ type responsesFailedBody struct {
}
// responsesFailedEvent 是写入 SSE data 行的顶层结构。
// 故意不带 sequence_number:spec 标记可选,且本函数被调用时无法可靠拿到 last seq。
// SequenceNumber 不带 omitempty:grok-build 等 Rust serde 客户端把它当必填字段,
// 缺失即 `missing field 'sequence_number'`。拿不到上一帧序号时写 0——这是终止事件,
// 后面不会再续流。
type responsesFailedEvent struct {
Type string `json:"type"`
Response responsesFailedBody `json:"response"`
Type string `json:"type"`
SequenceNumber int `json:"sequence_number"`
Response responsesFailedBody `json:"response"`
}
// writeResponsesFailedSSE emits a `response.failed` SSE event in the OpenAI
@@ -49,8 +52,7 @@ type responsesFailedEvent struct {
// 而抛出 "stream closed before response.completed"。
//
// 字段集对齐 apicompat.makeResponsesCompletedEvent:id/object/model/status/output/error。
// 故意不写 sequence_number:本函数被调用时无法可靠拿到当前流的 last sequence,
// 而 OpenAI spec 将 sequence_number 设为可选;省略避免破坏单调性约束。
// sequence_number 始终写出(未知时为 0):OpenAI spec 标可选,但 grok-build 当必填。
//
// 返回 true 表示已尝试 SSE 写出(不论 Write 是否成功,caller 都应直接 return)。
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
@@ -48,9 +48,12 @@ func parseResponsesFailedSSE(t *testing.T, body string) (map[string]any, map[str
require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed), "data must be valid JSON: %s", jsonStr)
assert.Equal(t, "response.failed", parsed["type"])
// 故意不发 sequence_number,避免与后续真实事件的序号冲突。
_, hasSeq := parsed["sequence_number"]
assert.False(t, hasSeq, "synthetic event must not emit sequence_number")
// grok-build 把 sequence_number 当必填;合成终止事件未知上一帧时写 0。
rawSeq, hasSeq := parsed["sequence_number"]
assert.True(t, hasSeq, "synthetic event must emit sequence_number")
seq, ok := rawSeq.(float64)
assert.True(t, ok, "sequence_number must be a number, got %T", rawSeq)
assert.GreaterOrEqual(t, seq, float64(0))
resp, ok := parsed["response"].(map[string]any)
require.True(t, ok, "response object missing")
@@ -316,7 +319,20 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t
assert.Equal(t, "rate_limit_exceeded", errObj["code"])
}
// Synthesized response.failed id falls back to uuid when no request_id is present.
// issue #7128:grok-build 把顶层 sequence_number 当必填,合成的 response.failed
// 必须写出该字段,否则整轮反序列化失败。
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesSequenceNumber(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
require.True(t, strings.HasPrefix(w.Body.String(), "event: response.failed\n"))
_, payload, found := strings.Cut(w.Body.String(), "data: ")
require.True(t, found)
require.True(t, gjson.Get(payload, "sequence_number").Exists(), "response.failed 必须带 sequence_number")
require.GreaterOrEqual(t, gjson.Get(payload, "sequence_number").Int(), int64(0))
}
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
// (给客户端一个可识别的终止事件而不是盲重连)就落空了。
@@ -333,6 +349,7 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *t
assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
}
// Synthesized response.failed id falls back to uuid when no request_id is present.
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
c, _ := newGinContextForEndpoint(t, EndpointResponses)
id := synthesizeResponseID(c)
@@ -132,6 +132,31 @@ func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
require.Contains(t, m, "response")
}
// grok-build 把 sequence_number 当必填。response.created 从 0 起号,
// omitempty 会把 0 整段丢掉,第一帧就反序列化失败。
func TestWire_SequenceNumberPresentAtZero(t *testing.T) {
created := marshalEvent(t, ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"},
})
require.Contains(t, created, "sequence_number")
require.EqualValues(t, 0, created["sequence_number"])
completed := marshalEvent(t, ResponsesStreamEvent{
Type: "response.completed",
SequenceNumber: 0,
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "completed"},
})
require.Contains(t, completed, "sequence_number")
require.EqualValues(t, 0, completed["sequence_number"])
delta := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1", Delta: "hi",
})
require.Contains(t, delta, "sequence_number")
require.EqualValues(t, 0, delta["sequence_number"])
}
func TestResponsesOutputUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var item ResponsesOutput
require.NoError(t, json.Unmarshal([]byte(`{
+5 -2
View File
@@ -642,8 +642,11 @@ type ResponsesStreamEvent struct {
Code string `json:"code,omitempty"`
Param string `json:"param,omitempty"`
// Sequence number for ordering events
SequenceNumber int `json:"sequence_number,omitempty"`
// SequenceNumber orders streamed events. Strict Responses clients (Grok Build,
// Codex CLI) declare it required and abort with `missing field 'sequence_number'`
// when it is absent, so it is always emitted — no omitempty. Same rule as
// ResponsesResponse.CreatedAt. Zero is a valid first-event value.
SequenceNumber int `json:"sequence_number"`
}
// ---------------------------------------------------------------------------
+7
View File
@@ -848,6 +848,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
//
// 例外:DeepSeek 平台的空映射不再是「允许所有」,改按官方模型白名单判定
// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发
// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。
func (a *Account) IsModelSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
@@ -861,6 +865,9 @@ func (a *Account) IsModelSupported(requestedModel string) bool {
if a.IsOpenAIOAuth() {
return isOpenAIOAuthServableModel(requestedModel)
}
if a.Platform == PlatformDeepseek {
return isDeepseekServableModel(requestedModel)
}
return true // 无映射 = 允许所有
}
if mappingSupportsRequestedModel(mapping, requestedModel) {
@@ -231,6 +231,82 @@ func TestAccountIsModelSupported(t *testing.T) {
requestedModel: "gemini-3-flash",
expected: false,
},
// DeepSeek 空映射:官方模型白名单(不再是「允许所有」)
{
name: "deepseek empty mapping allows official flash",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flash",
expected: true,
},
{
name: "deepseek empty mapping normalizes claude code long context suffix",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flash[1m]",
expected: true,
},
{
name: "deepseek empty mapping matches case-insensitively",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-FLASH",
expected: true,
},
{
name: "deepseek empty mapping allows versioned pro name",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-v4-pro-0813",
expected: true,
},
{
name: "deepseek empty mapping rejects retired chat model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-chat",
expected: false,
},
{
name: "deepseek empty mapping rejects foreign claude model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "claude-sonnet-4-6",
expected: false,
},
{
name: "deepseek empty mapping rejects unknown gpt model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "gpt-future-model",
expected: false,
},
{
name: "deepseek empty mapping rejects typo",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flas",
expected: false,
},
{
name: "deepseek explicit mapping wins over whitelist",
platform: PlatformDeepseek,
credentials: map[string]any{
"model_mapping": map[string]any{
"foo-bar": "deepseek-flash",
},
},
requestedModel: "foo-bar",
expected: true,
},
{
name: "non deepseek empty mapping still allows all",
platform: PlatformAnthropic,
credentials: map[string]any{},
requestedModel: "any-model",
expected: true,
},
}
for _, tt := range tests {
@@ -1387,6 +1387,49 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
// 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
// 回归:上游事件之间的空分隔行不能再透传,否则下游看到的是 "data: ...\n\n\n"。
// google-genai Go SDK 按 "\n\n" 切事件,多出的 "\n" 会让第二个事件的前缀变成
// "\ndata" 而报 invalid stream chunk(Antigravity CLI 每条流都在第二个事件中断)。
func TestHandleGeminiStreamingResponse_EventSeparatorIsExactlyOneBlankLine(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
pr, pw := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
first := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}`
second := `{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":5}}`
go func() {
defer func() { _ = pw.Close() }()
// 上游原样:每个 data 行后跟一个空分隔行(第二个事件用 CRLF,覆盖两种换行)
fmt.Fprintf(pw, "data: %s\n\n", first)
fmt.Fprintf(pw, "data: %s\r\n\r\n", second)
}()
result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
body := rec.Body.String()
require.Equal(t, "data: "+first+"\n\ndata: "+second+"\n\n", body)
require.NotContains(t, body, "\n\n\n", "events must be separated by exactly one blank line")
// 模拟 google-genai 的切帧方式:按 "\n\n" 切,每个非空 token 都必须以 "data" 为前缀
for _, token := range strings.Split(strings.TrimSuffix(body, "\n\n"), "\n\n") {
prefix, _, _ := strings.Cut(token, ":")
require.Equal(t, "data", prefix, "token %q would be rejected by a \\n\\n-delimited SSE parser", token)
}
}
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
@@ -288,6 +288,14 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
continue
}
// 上游每个 data 事件后面跟一个空行作为事件分隔。上面已经把 data 行写成
// "data: ...\n\n",若再把这个空行透传出去,事件之间就会变成 "\n\n\n"。
// google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,多出的
// "\n" 会粘到下一个事件开头,前缀变成 "\ndata" 而被判成 invalid stream chunk。
if trimmed == "" {
continue
}
cw.Fprintf("%s\n", line)
case <-intervalCh:
@@ -961,15 +961,15 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
}
}
// Reset 因子(use-it-or-lose-it):在拥有「未来会话窗口结束时间」的账号中,
// Reset 因子(use-it-or-lose-it):优先读取 Codex 5h 重置时间,回退到会话窗口。
// 剩余时间越短 → 因子越接近 1(越早重置越优先用尽)。无活跃窗口的账号因子为 0。
// 仅在 weights.Reset > 0 时计算,默认关闭不影响原有行为。
minResetRemaining, maxResetRemaining := 0.0, 0.0
hasResetSample := false
if weights.Reset > 0 {
for _, candidate := range candidates {
end := candidate.account.SessionWindowEnd
if end == nil || !now.Before(*end) {
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
if !ok {
continue
}
remaining := end.Sub(now).Seconds()
@@ -1002,7 +1002,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
}
resetFactor := 0.0
if weights.Reset > 0 && hasResetSample {
if end := item.account.SessionWindowEnd; end != nil && now.Before(*end) {
if end, ok := openAISchedulingResetWindowEnd(item.account, now); ok {
if maxResetRemaining > minResetRemaining {
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
} else {
@@ -2732,8 +2732,8 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
}
if weights.Reset > 0 {
for _, candidate := range candidates {
end := candidate.account.SessionWindowEnd
if end == nil || !now.Before(*end) {
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
if !ok {
continue
}
remaining := end.Sub(now).Seconds()
@@ -2763,7 +2763,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
ttftFactor := 0.5
resetFactor := 0.0
if weights.Reset > 0 && hasResetSample {
if end := candidate.account.SessionWindowEnd; end != nil && now.Before(*end) {
if end, ok := openAISchedulingResetWindowEnd(candidate.account, now); ok {
if maxResetRemaining > minResetRemaining {
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
} else {
@@ -2952,22 +2952,89 @@ func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 {
if account == nil || len(account.Extra) == 0 || openAIQuotaHeadroomSnapshotStale(account.Extra, now) {
return openAIQuotaHeadroomNeutralFactor
}
primaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_primary_used_percent", "codex_7d_used_percent")
if !ok || openAIQuotaWindowResetAny(account.Extra, now, "primary", "7d") {
window5h, window7d := openAICanonicalQuotaWindows(account.Extra, now)
if !window7d.hasUsed || window7d.reset {
return openAIQuotaHeadroomNeutralFactor
}
factor := 1 - clamp01(primaryUsedPercent/100)
if secondaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_secondary_used_percent", "codex_5h_used_percent"); ok &&
!openAIQuotaWindowResetAny(account.Extra, now, "secondary", "5h") {
secondaryRemaining := 1 - clamp01(secondaryUsedPercent/100)
if secondaryRemaining < openAIQuotaHeadroomSecondaryLowRemain {
factor := 1 - clamp01(window7d.usedPercent/100)
if window5h.hasUsed && !window5h.reset {
remaining := 1 - clamp01(window5h.usedPercent/100)
if remaining < openAIQuotaHeadroomSecondaryLowRemain {
factor *= openAIQuotaHeadroomNeutralFactor
}
}
return factor
}
type openAICanonicalQuotaWindow struct {
usedPercent float64
hasUsed bool
reset bool
}
// 规范字段优先;历史原始字段复用写入端 Normalize 的窗口分类,不能固定把 primary 当作 7d。
func openAICanonicalQuotaWindows(extra map[string]any, now time.Time) (window5h, window7d openAICanonicalQuotaWindow) {
if used, ok := resolveAccountExtraNumber(extra, "codex_5h_used_percent"); ok {
window5h = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "5h", now)}
}
if used, ok := resolveAccountExtraNumber(extra, "codex_7d_used_percent"); ok {
window7d = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "7d", now)}
}
if window5h.hasUsed && window7d.hasUsed {
return window5h, window7d
}
snapshot := &OpenAICodexUsageSnapshot{}
if used, ok := resolveAccountExtraNumber(extra, "codex_primary_used_percent"); ok {
snapshot.PrimaryUsedPercent = &used
}
if used, ok := resolveAccountExtraNumber(extra, "codex_secondary_used_percent"); ok {
snapshot.SecondaryUsedPercent = &used
}
if minutes := parseExtraInt(extra["codex_primary_window_minutes"]); minutes > 0 {
snapshot.PrimaryWindowMinutes = &minutes
}
if minutes := parseExtraInt(extra["codex_secondary_window_minutes"]); minutes > 0 {
snapshot.SecondaryWindowMinutes = &minutes
}
normalized := snapshot.Normalize()
if normalized == nil {
return window5h, window7d
}
fromRaw := func(used *float64) openAICanonicalQuotaWindow {
if used == nil {
return openAICanonicalQuotaWindow{}
}
// Normalize 保留原始字段指针,据此配对同一个窗口的用量和重置时间。
window := "secondary"
if used == snapshot.PrimaryUsedPercent {
window = "primary"
}
return openAICanonicalQuotaWindow{usedPercent: *used, hasUsed: true, reset: openAIQuotaWindowReset(extra, window, now)}
}
if !window5h.hasUsed {
window5h = fromRaw(normalized.Used5hPercent)
}
if !window7d.hasUsed {
window7d = fromRaw(normalized.Used7dPercent)
}
return window5h, window7d
}
func openAISchedulingResetWindowEnd(account *Account, now time.Time) (time.Time, bool) {
if account == nil {
return time.Time{}, false
}
if end, ok := openAICodexWindowResetAt(account.Extra, "5h"); ok && now.Before(end) {
return end, true
}
if end := account.SessionWindowEnd; end != nil && now.Before(*end) {
return *end, true
}
return time.Time{}, false
}
func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool {
updatedRaw, ok := extra["codex_usage_updated_at"]
if !ok {
@@ -2980,15 +3047,6 @@ func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool
return now.Sub(updatedAt) >= openAIQuotaHeadroomSnapshotStaleAfter
}
func openAIQuotaWindowResetAny(extra map[string]any, now time.Time, windows ...string) bool {
for _, window := range windows {
if openAIQuotaWindowReset(extra, window, now) {
return true
}
}
return false
}
func clamp01(value float64) float64 {
switch {
case value < 0:
@@ -0,0 +1,141 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestOpenAIQuotaHeadroomFactorUsesCanonicalWindows(t *testing.T) {
now := time.Now()
shortMins, longMins := 300, 10080
shortReset, longReset := 3600, 86400
shortUsed, longUsed := 96.0, 25.0
for _, primaryIs5h := range []bool{true, false} {
name := "primary_is_7d"
snapshot := &OpenAICodexUsageSnapshot{
PrimaryUsedPercent: &longUsed, PrimaryWindowMinutes: &longMins, PrimaryResetAfterSeconds: &longReset,
SecondaryUsedPercent: &shortUsed, SecondaryWindowMinutes: &shortMins, SecondaryResetAfterSeconds: &shortReset,
}
if primaryIs5h {
name = "primary_is_5h"
snapshot = &OpenAICodexUsageSnapshot{
PrimaryUsedPercent: &shortUsed, PrimaryWindowMinutes: &shortMins, PrimaryResetAfterSeconds: &shortReset,
SecondaryUsedPercent: &longUsed, SecondaryWindowMinutes: &longMins, SecondaryResetAfterSeconds: &longReset,
}
}
t.Run(name, func(t *testing.T) {
for _, rawOnly := range []bool{false, true} {
fields := "canonical_and_raw"
if rawOnly {
fields = "raw_only"
}
t.Run(fields, func(t *testing.T) {
extra := buildCodexUsageExtraUpdates(snapshot, now)
if rawOnly {
for key := range extra {
if strings.HasPrefix(key, "codex_5h_") || strings.HasPrefix(key, "codex_7d_") {
delete(extra, key)
}
}
}
// 周余量 75%,5h 余量低于 10% 时沿用现有的减半规则。
require.InDelta(t, 0.375, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
})
}
})
}
}
func TestOpenAIQuotaHeadroomFactorCanonicalPrecedence(t *testing.T) {
now := time.Now()
extra := map[string]any{
"codex_primary_used_percent": 40.0,
"codex_secondary_used_percent": 20.0,
"codex_usage_updated_at": now.Format(time.RFC3339),
}
// 没有窗口长度的历史数据仍沿用 primary=7d、secondary=5h。
require.InDelta(t, 0.6, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_7d_used_percent"] = 80.0
extra["codex_5h_used_percent"] = 10.0
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
// 规范窗口有效时,不应被另一个原始窗口的过期时间误判为已重置。
extra["codex_7d_reset_at"] = now.Add(time.Hour).Format(time.RFC3339)
extra["codex_primary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_7d_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
}
func TestOpenAIQuotaHeadroomFactorRawWindowReset(t *testing.T) {
now := time.Now()
extra := map[string]any{
"codex_primary_used_percent": 96.0,
"codex_primary_window_minutes": 300,
"codex_primary_reset_at": now.Add(-time.Minute).Format(time.RFC3339),
"codex_secondary_used_percent": 25.0,
"codex_secondary_reset_at": now.Add(time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Hour).Format(time.RFC3339),
}
require.InDelta(t, 0.75, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_secondary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
}
func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) {
now := time.Now()
scheduler := openAIResetTestScheduler(1)
accounts := []*Account{
{ID: 1, Extra: map[string]any{"codex_5h_reset_at": now.Add(10 * time.Minute).Format(time.RFC3339)}},
{ID: 2, Extra: map[string]any{"codex_5h_reset_at": now.Add(4 * time.Hour).Format(time.RFC3339)}},
}
plan := scheduler.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, accounts, nil)
scores := openAIPlanScores(plan)
require.InDelta(t, 1, scores[1]-scores[2], 1e-9)
snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier)
for _, account := range accounts {
require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9)
}
}
func TestOpenAISchedulingResetWindowEnd(t *testing.T) {
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
updated := now.Add(-30 * time.Minute)
account := &Account{Extra: map[string]any{
"codex_usage_updated_at": updated.Format(time.RFC3339),
"codex_5h_reset_after_seconds": 3600,
}}
end, ok := openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, updated.Add(time.Hour), end)
account.Extra["codex_5h_reset_at"] = now.Add(10 * time.Minute).Format(time.RFC3339)
end, ok = openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, now.Add(10*time.Minute), end)
delete(account.Extra, "codex_5h_reset_at")
account.Extra["codex_5h_reset_after_seconds"] = 60
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok)
account.Extra["codex_5h_reset_after_seconds"] = 3600
delete(account.Extra, "codex_usage_updated_at")
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok, "缺少采样时间时不能把相对倒计时锚定到当前时间")
account.Extra["codex_usage_updated_at"] = "invalid"
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok)
sessionEnd := now.Add(2 * time.Hour)
account.SessionWindowEnd = &sessionEnd
end, ok = openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, sessionEnd, end)
_, ok = openAISchedulingResetWindowEnd(nil, now)
require.False(t, ok)
}
@@ -1228,7 +1228,15 @@ func normalizeOpenAIModelForUpstream(account *Account, model string) string {
if account == nil || account.UsesOpenAICodexProtocol() {
return normalizeCodexModel(model)
}
return strings.TrimSpace(model)
model = strings.TrimSpace(model)
if account.Platform == PlatformDeepseek {
// DeepSeek 走 OpenAI 兼容入口,不经 Anthropic 入站的 [1m] 后缀归一
// (parseGatewayRequestCurrentBody 仅处理 PlatformAnthropic 协议)。
// 官方 Claude Code 接入要求 ANTHROPIC_MODEL=deepseek-flash[1m] 写法,
// 出站前剥离泄漏的客户端上下文后缀,转发规范名给上游。
return normalizeClaudeCodeLongContextModel(model)
}
return model
}
func SupportsVerbosity(model string) bool {
@@ -114,7 +114,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
}
MarkOpsStreamError(c, errType, message, statusCode)
payload, err := json.Marshal(map[string]any{
"type": "response.failed",
"type": "response.failed",
"sequence_number": 0,
"response": map[string]any{
"id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
"object": "response",
@@ -143,7 +144,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
// 携带完整 response 对象。Codex 的 SSE 解析只从 output_item.done 收集 item,
// 并要求 response.completed 的 response.id 必填、usage(若存在)必须携带
// input_tokens/output_tokens/total_tokens 整数字段,否则整条 completed 事件
// 解析失败,故此处做兜底修补。
// 解析失败,故此处做兜底修补。每帧还要带单调的 sequence_number:grok-build
// 把它当必填,缺了整轮反序列化失败。
func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if len(finalResponse) == 0 || !gjson.ValidBytes(finalResponse) {
return nil, false
@@ -176,12 +178,19 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
var buf bytes.Buffer
outputIndex := 0
appendEvent := func(eventType string, data []byte) {
sequenceNumber := 0
appendEvent := func(eventType string, data []byte) bool {
numbered, err := sjson.SetBytes(data, "sequence_number", sequenceNumber)
if err != nil {
return false
}
sequenceNumber++
_, _ = buf.WriteString("event: ")
_, _ = buf.WriteString(eventType)
_, _ = buf.WriteString("\ndata: ")
_, _ = buf.Write(data)
_, _ = buf.Write(numbered)
_, _ = buf.WriteString("\n\n")
return true
}
for _, item := range gjson.GetBytes(response, "output").Array() {
if !item.IsObject() {
@@ -195,7 +204,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if err != nil {
return nil, false
}
appendEvent("response.output_item.done", event)
if !appendEvent("response.output_item.done", event) {
return nil, false
}
outputIndex++
}
@@ -203,7 +214,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if err != nil {
return nil, false
}
appendEvent("response.completed", completed)
if !appendEvent("response.completed", completed) {
return nil, false
}
return buf.Bytes(), true
}
@@ -73,6 +73,8 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
require.Equal(t, "response.output_item.done", events[0][0])
first := events[0][1]
require.Equal(t, "response.output_item.done", gjson.Get(first, "type").String())
require.True(t, gjson.Get(first, "sequence_number").Exists(), "compact 合成帧必须带 sequence_number")
require.Equal(t, int64(0), gjson.Get(first, "sequence_number").Int())
require.Equal(t, int64(0), gjson.Get(first, "output_index").Int())
require.Equal(t, "compaction", gjson.Get(first, "item.type").String())
require.Equal(t, "cmp_1", gjson.Get(first, "item.id").String())
@@ -81,12 +83,14 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
require.True(t, gjson.Get(first, "item.opaque.kept").Bool(), "item 原始字段必须逐字节保留")
require.Equal(t, "response.output_item.done", events[1][0])
require.Equal(t, int64(1), gjson.Get(events[1][1], "sequence_number").Int())
require.Equal(t, int64(1), gjson.Get(events[1][1], "output_index").Int())
require.Equal(t, "message", gjson.Get(events[1][1], "item.type").String())
require.Equal(t, "response.completed", events[2][0])
completed := events[2][1]
require.Equal(t, "response.completed", gjson.Get(completed, "type").String())
require.Equal(t, int64(2), gjson.Get(completed, "sequence_number").Int())
require.Equal(t, "resp_compact_1", gjson.Get(completed, "response.id").String())
require.Equal(t, int64(13), gjson.Get(completed, "response.usage.total_tokens").Int())
require.Len(t, gjson.Get(completed, "response.output").Array(), 2)
@@ -31,8 +31,9 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
require.True(t, found, "SSE 帧必须带 data 行: %q", body)
var event struct {
Type string `json:"type"`
Response struct {
Type string `json:"type"`
SequenceNumber *int `json:"sequence_number"`
Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
@@ -42,6 +43,8 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(payload)), &event))
require.Equal(t, "response.failed", event.Type)
require.NotNil(t, event.SequenceNumber, "response.failed 必须带 sequence_number,否则 grok-build 读不出这帧")
require.GreaterOrEqual(t, *event.SequenceNumber, 0)
require.Equal(t, "response", event.Response.Object)
require.Equal(t, "failed", event.Response.Status)
require.Greater(t, event.Response.CreatedAt, int64(0),
@@ -710,26 +710,29 @@ func openAICodexSnapshotStaleForPause(extra map[string]any, now time.Time) bool
// timestamp and falls back to codex_<window>_reset_after_seconds anchored at
// codex_usage_updated_at, mirroring AccountUsageService's window-progress logic.
func openAIQuotaWindowReset(extra map[string]any, window string, now time.Time) bool {
resetAt, ok := openAICodexWindowResetAt(extra, window)
return ok && !now.Before(resetAt)
}
// 绝对时间优先;相对倒计时必须锚定快照采样时间,不能随每次评分向后滑动。
func openAICodexWindowResetAt(extra map[string]any, window string) (time.Time, bool) {
if len(extra) == 0 {
return false
return time.Time{}, false
}
if resetAtRaw, ok := extra["codex_"+window+"_reset_at"]; ok {
if resetAt, err := parseTime(fmt.Sprint(resetAtRaw)); err == nil {
return !now.Before(resetAt)
return resetAt, true
}
}
resetAfter := parseExtraInt(extra["codex_"+window+"_reset_after_seconds"])
if resetAfter <= 0 {
return false
return time.Time{}, false
}
base := now
if updatedRaw, ok := extra["codex_usage_updated_at"]; ok {
if updatedAt, err := parseTime(fmt.Sprint(updatedRaw)); err == nil {
base = updatedAt
}
updatedAt, err := parseTime(fmt.Sprint(extra["codex_usage_updated_at"]))
if err != nil {
return time.Time{}, false
}
resetAt := base.Add(time.Duration(resetAfter) * time.Second)
return !now.Before(resetAt)
return updatedAt.Add(time.Duration(resetAfter) * time.Second), true
}
func readOpenAIQuotaUsedPercent(extra map[string]any, window string) float64 {
@@ -82,6 +82,43 @@ func isOpenAIOAuthServableModel(requestedModel string) bool {
return true
}
// deepseekServableModels 列出 DeepSeek 平台账号在「未配置 model_mapping」时
// 可服务的官方模型名(精确匹配,小写比较)。deepseek-flash 与 deepseek-v4-pro
// 为官方现行名;deepseek-v4-flash / deepseek-v4-flash-vision-exp 为处于兼容
// 路由期的旧名(上游仍接受并按 flash 价计费);deepseek-v4-pro-0813 为与
// 计费 pro 档口径一致的版本化名。
var deepseekServableModels = []string{
"deepseek-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
"deepseek-v4-pro-0813",
}
// isDeepseekServableModel 报告 DeepSeek 平台账号在「未配置 model_mapping」时
// 可服务的模型名。官方现行模型为 deepseek-flash 与 deepseek-v4-pro;旧名
// deepseek-v4-flash / deepseek-v4-flash-vision-exp 处于兼容路由期(上游仍接受并
// 按 flash 价计费),deepseek-v4-pro-0813 为版本化名。其余一律拒绝:既避免把
// 未知模型透传给上游(上游 404/400 会触发 per-model 冷却并掩盖真实配置错误),
// 也避免上游把不认识的模型名静默兜底成 deepseek-flash。
// Source: https://api-docs.deepseek.com/quick_start/pricing
//
// https://api-docs.deepseek.com/guides/anthropic_api
func isDeepseekServableModel(requestedModel string) bool {
// 官方 Claude Code 接入文档要求 ANTHROPIC_MODEL=deepseek-flash[1m]:
// [1m] 是客户端上下文选择器,先按同一规则归一化再比对白名单。
model := strings.ToLower(normalizeClaudeCodeLongContextModel(strings.TrimSpace(requestedModel)))
if model == "" {
return true // 空模型交由上层必填校验处理
}
for _, servable := range deepseekServableModels {
if model == servable {
return true
}
}
return false
}
// resolveOpenAICompactForwardModel determines the compact-only upstream model
// for /responses/compact requests. It never affects normal /responses traffic.
// When no compact-specific mapping matches, the input model is returned as-is.
@@ -429,6 +429,30 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
model: "gpt-4.1",
want: "gpt-4.1",
},
{
name: "deepseek strips claude code long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash[1m]",
want: "deepseek-flash",
},
{
name: "deepseek strips duplicated long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash[1M][1m]",
want: "deepseek-flash",
},
{
name: "deepseek preserves plain model",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash",
want: "deepseek-flash",
},
{
name: "non deepseek preserves long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformOpenAI},
model: "deepseek-flash[1m]",
want: "deepseek-flash[1m]",
},
}
for _, tt := range tests {
@@ -354,8 +354,9 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
message = "upstream request failed"
}
event := map[string]any{
"type": "error",
"status": statusCode,
"type": "error",
"sequence_number": 0,
"status": statusCode,
"error": map[string]any{
"type": "upstream_error",
"message": message,
@@ -363,7 +364,7 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
}
body, err := json.Marshal(event)
if err != nil {
return []byte(`{"type":"error","error":{"type":"upstream_error","message":"upstream request failed"}}`)
return []byte(`{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":"upstream request failed"}}`)
}
return body
}
@@ -398,9 +399,9 @@ func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte,
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
body, err := json.Marshal(map[string]any{"type": "response.failed", "response": response})
body, err := json.Marshal(map[string]any{"type": "response.failed", "sequence_number": 0, "response": response})
if err != nil {
return []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
return []byte(`{"type":"response.failed","sequence_number":0,"response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return body
}
@@ -80,6 +80,29 @@ describe('EmailOAuthButtons', () => {
expect(window.location.href).toBe(originalHref)
})
it('omits an empty promo code from the OAuth request', async () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
githubEnabled: true,
googleEnabled: false,
promoCode: ' ',
},
global: {
stubs: {
GitHubMark: true,
GoogleMark: true,
},
},
})
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
provider: 'github',
params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' }
})
})
it('uses a full-width descriptive button when only GitHub is enabled', () => {
const wrapper = mount(EmailOAuthButtons, {
props: {
+13
View File
@@ -116,6 +116,19 @@ export default {
nameLabel: 'Name',
namePlaceholder: 'My API Key',
groupLabel: 'Group',
providerLabel: 'Provider',
providers: {
anthropic: 'Anthropic',
openai: 'OpenAI',
domestic: 'Chinese AI',
other: 'Other'
},
providerHints: {
anthropic: 'Choose an available Anthropic / Claude group',
openai: 'Choose an available OpenAI / GPT group',
domestic: 'Includes DeepSeek, Kimi, Zhipu GLM and MiniMax',
other: 'Includes Gemini, Grok, Antigravity, OpenCode and mixed groups'
},
selectGroup: 'Select a group',
statusLabel: 'Status',
selectStatus: 'Select status',
+13
View File
@@ -116,6 +116,19 @@ export default {
nameLabel: '名称',
namePlaceholder: '我的 API 密钥',
groupLabel: '分组',
providerLabel: '厂商',
providers: {
anthropic: 'Anthropic',
openai: 'OpenAI',
domestic: '国产模型',
other: '其他'
},
providerHints: {
anthropic: '选择 Anthropic / Claude 的可用分组',
openai: '选择 OpenAI / GPT 的可用分组',
domestic: '包含 DeepSeek、Kimi、智谱 GLM、MiniMax',
other: '包含 Gemini、Grok、Antigravity、OpenCode 和混合分组'
},
selectGroup: '选择分组',
statusLabel: '状态',
selectStatus: '选择状态',
+32
View File
@@ -0,0 +1,32 @@
import type { GroupPlatform } from '@/types'
export type KeyGroupProvider = 'anthropic' | 'openai' | 'domestic' | 'other'
export const KEY_GROUP_PROVIDERS = ['anthropic', 'openai', 'domestic', 'other'] as const
// Classify by the configured upstream platform, never by a group's display name.
const PROVIDER_BY_PLATFORM: Record<GroupPlatform, KeyGroupProvider> = {
anthropic: 'anthropic',
openai: 'openai',
kimi: 'domestic',
zhipu: 'domestic',
deepseek: 'domestic',
minimax: 'domestic',
gemini: 'other',
grok: 'other',
antigravity: 'other',
composite: 'other',
opencode_go: 'other'
}
export function getKeyGroupProvider(platform: GroupPlatform): KeyGroupProvider {
return PROVIDER_BY_PLATFORM[platform] ?? 'other'
}
// Collections use representative provider marks rather than an invented brand logo.
export const KEY_GROUP_PROVIDER_ICONS: Record<KeyGroupProvider, GroupPlatform[]> = {
anthropic: ['anthropic'],
openai: ['openai'],
domestic: ['deepseek', 'kimi'],
other: ['gemini', 'grok']
}
+87 -3
View File
@@ -485,12 +485,64 @@
/>
</div>
<fieldset v-if="!showEditModal" data-tour="key-form-provider">
<legend class="input-label">{{ t('keys.providerLabel') }}</legend>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<label
v-for="provider in createProviderOptions"
:key="provider.value"
class="relative min-w-0"
:class="provider.count === 0 ? 'cursor-not-allowed' : 'cursor-pointer'"
>
<input
type="radio"
name="key-provider"
:value="provider.value"
:checked="createProvider === provider.value"
:disabled="provider.count === 0"
class="peer sr-only"
@change="selectCreateProvider(provider.value)"
/>
<span
class="flex h-full flex-col items-center gap-2 rounded-xl border border-gray-200 bg-white px-2 py-3 text-center transition-colors peer-checked:border-primary-500 peer-checked:bg-primary-50/60 peer-checked:ring-1 peer-checked:ring-primary-500 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary-500 peer-disabled:opacity-40 dark:border-dark-600 dark:bg-dark-800 dark:peer-checked:border-primary-500 dark:peer-checked:bg-primary-500/10"
:class="provider.count > 0 && 'hover:border-primary-300 dark:hover:border-primary-700'"
>
<span class="flex h-8 items-center justify-center gap-1.5" aria-hidden="true">
<span
v-for="platform in KEY_GROUP_PROVIDER_ICONS[provider.value]"
:key="platform"
class="flex h-8 w-8 items-center justify-center rounded-lg"
:class="platformBadgeLightClass(platform)"
>
<PlatformIcon :platform="platform" size="lg" />
</span>
</span>
<span class="text-sm font-semibold text-gray-800 dark:text-gray-100">{{ provider.label }}</span>
</span>
<span
v-if="createProvider === provider.value"
class="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 text-white"
aria-hidden="true"
>
<Icon name="check" size="xs" :stroke-width="3" />
</span>
</label>
</div>
<p class="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-400" aria-live="polite">
{{ groups.length === 0 ? t('common.noGroupsAvailable') : t(`keys.providerHints.${createProvider}`) }}
</p>
</fieldset>
<div>
<label class="input-label">{{ t('keys.groupLabel') }}</label>
<label class="input-label" for="key-form-group">{{ t('keys.groupLabel') }}</label>
<Select
:key="showEditModal ? 'edit' : createProvider"
id="key-form-group"
:aria-label="t('keys.groupLabel')"
v-model="formData.group_id"
:options="groupOptions"
:options="formGroupOptions"
:placeholder="t('keys.selectGroup')"
:empty-text="t('common.noGroupsAvailable')"
:searchable="true"
:search-placeholder="t('keys.searchGroup')"
data-tour="key-form-group"
@@ -1146,7 +1198,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { ref, reactive, computed, watch, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useOnboardingStore } from '@/stores/onboarding'
@@ -1175,6 +1227,9 @@ import type { Column } from '@/components/common/types'
import type { BatchApiKeyUsageStats } from '@/api/usage'
import { formatDateTime } from '@/utils/format'
import { maskApiKey } from '@/utils/maskApiKey'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import { platformBadgeLightClass } from '@/utils/platformColors'
import { KEY_GROUP_PROVIDERS, KEY_GROUP_PROVIDER_ICONS, getKeyGroupProvider, type KeyGroupProvider } from '@/utils/keyGroupProviders'
import {
buildCcSwitchImportDeeplink,
type CcSwitchClientType
@@ -1470,6 +1525,35 @@ const groupOptions = computed(() =>
}))
)
const createProvider = ref<KeyGroupProvider>('anthropic')
const createProviderOptions = computed(() => KEY_GROUP_PROVIDERS.map((value) => ({
value,
label: t(`keys.providers.${value}`),
count: groups.value.filter((group) => getKeyGroupProvider(group.platform) === value).length
})))
const formGroupOptions = computed(() => showEditModal.value
? groupOptions.value
: groupOptions.value.filter((group) => getKeyGroupProvider(group.platform) === createProvider.value)
)
const selectCreateProvider = (provider: KeyGroupProvider) => {
if (createProvider.value === provider) return
createProvider.value = provider
formData.value.group_id = null
}
// Also handles groups arriving after the create dialog has already opened.
watch([showCreateModal, createProviderOptions], ([isOpen, providers], [wasOpen]) => {
if (!isOpen) return
if (!wasOpen || !providers.some((provider) => provider.value === createProvider.value && provider.count > 0)) {
selectCreateProvider(providers.find((provider) => provider.count > 0)?.value ?? 'anthropic')
}
if (!formGroupOptions.value.some((group) => group.value === formData.value.group_id)) {
formData.value.group_id = null
}
})
// Group dropdown search
const groupSearchQuery = ref('')
const filteredGroupOptions = computed(() => {
@@ -3,6 +3,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import type { ApiKey } from '@/types'
import { keysAPI } from '@/api'
import KeysView from '../KeysView.vue'
const {
@@ -227,8 +228,9 @@ const mountView = async () => {
DataTable: DataTableStub,
Pagination: PaginationStub,
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
props: ['show', 'title'],
emits: ['close'],
template: '<div v-if="show" role="dialog"><button data-test="close-dialog" @click="$emit(\'close\')">Close</button><slot /><slot name="footer" /></div>',
},
ConfirmDialog: true,
EmptyState: true,
@@ -269,6 +271,7 @@ describe('user KeysView column settings', () => {
listKeys.mockReset()
updateKey.mockReset()
vi.mocked(keysAPI.create).mockReset()
getPublicSettings.mockReset()
getDashboardApiKeysUsage.mockReset()
getAvailableGroups.mockReset()
@@ -543,4 +546,99 @@ describe('user KeysView column settings', () => {
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
describe('create provider selection', () => {
const platforms = ['anthropic', 'openai', 'kimi', 'zhipu', 'deepseek', 'minimax', 'gemini', 'grok', 'antigravity', 'composite', 'opencode_go']
const availableGroups = platforms.map((platform, index) => ({
id: index + 1,
// Deliberately ambiguous names: classification must follow the platform.
name: `Shared group ${index + 1}`,
platform,
rate_multiplier: 1,
subscription_type: 'standard',
}))
const groupSelect = (wrapper: VueWrapper) => wrapper.findComponent('[data-tour="key-form-group"]')
const optionIds = (wrapper: VueWrapper) => groupSelect(wrapper).props('options').map((option: { value: number }) => option.value)
const chooseProvider = (wrapper: VueWrapper, value: string) => wrapper.get(`input[name="key-provider"][value="${value}"]`).setValue()
const openCreate = async () => {
const wrapper = await mountView()
await wrapper.get('[data-tour="keys-create-btn"]').trigger('click')
return wrapper
}
beforeEach(() => {
getAvailableGroups.mockResolvedValue(availableGroups)
})
it('classifies all configured platforms and retains the complete table filter', async () => {
const wrapper = await openCreate()
expect(wrapper.findAll('input[name="key-provider"]')).toHaveLength(4)
expect(optionIds(wrapper)).toEqual([1])
await chooseProvider(wrapper, 'openai')
expect(optionIds(wrapper)).toEqual([2])
await chooseProvider(wrapper, 'domestic')
expect(optionIds(wrapper)).toEqual([3, 4, 5, 6])
await chooseProvider(wrapper, 'other')
expect(optionIds(wrapper)).toEqual([7, 8, 9, 10, 11])
expect(wrapper.findAllComponents({ name: 'Select' })[0].props('options')).toHaveLength(13)
})
it('clears the previous group on provider change and submits only the newly selected group', async () => {
const wrapper = await openCreate()
await wrapper.get('[data-tour="key-form-name"]').setValue('My key')
await groupSelect(wrapper).vm.$emit('update:modelValue', 1)
await chooseProvider(wrapper, 'domestic')
expect(groupSelect(wrapper).props('modelValue')).toBeNull()
await wrapper.get('#key-form').trigger('submit')
expect(keysAPI.create).not.toHaveBeenCalled()
expect(showError).toHaveBeenCalledWith('keys.groupRequired')
await groupSelect(wrapper).vm.$emit('update:modelValue', 5)
vi.mocked(keysAPI.create).mockResolvedValue({ ...createApiKey(), group_id: 5 })
await wrapper.get('#key-form').trigger('submit')
await flushPromises()
expect(keysAPI.create).toHaveBeenCalledOnce()
expect(vi.mocked(keysAPI.create).mock.calls[0].slice(0, 2)).toEqual(['My key', 5])
})
it('defaults to a provider with available groups and disables empty categories', async () => {
getAvailableGroups.mockResolvedValue([availableGroups[5]])
const wrapper = await openCreate()
expect(wrapper.get<HTMLInputElement>('input[value="domestic"]').element.checked).toBe(true)
expect(wrapper.get<HTMLInputElement>('input[value="anthropic"]').element.disabled).toBe(true)
expect(optionIds(wrapper)).toEqual([6])
})
it('shows the empty state when no groups are available', async () => {
getAvailableGroups.mockResolvedValue([])
const wrapper = await openCreate()
expect(wrapper.get('[data-tour="key-form-provider"]').text()).toContain('common.noGroupsAvailable')
expect(optionIds(wrapper)).toEqual([])
expect(wrapper.findAll<HTMLInputElement>('input[name="key-provider"]').every((input) => input.element.disabled)).toBe(true)
})
it('selects an available provider when groups arrive after opening', async () => {
let resolveGroups!: (value: typeof availableGroups) => void
getAvailableGroups.mockReturnValue(new Promise((resolve) => { resolveGroups = resolve }))
const wrapper = await openCreate()
resolveGroups([availableGroups[1]])
await flushPromises()
expect(wrapper.get<HTMLInputElement>('input[value="openai"]').element.checked).toBe(true)
expect(optionIds(wrapper)).toEqual([2])
})
it('resets provider and group when reopening create, and preserves edit options', async () => {
const wrapper = await openCreate()
await chooseProvider(wrapper, 'domestic')
await groupSelect(wrapper).vm.$emit('update:modelValue', 5)
await wrapper.get('[data-test="close-dialog"]').trigger('click')
await wrapper.get('[data-tour="keys-create-btn"]').trigger('click')
expect(optionIds(wrapper)).toEqual([1])
expect(groupSelect(wrapper).props('modelValue')).toBeNull()
await wrapper.get('[data-test="close-dialog"]').trigger('click')
await getButtonByText(wrapper, 'common.edit').trigger('click')
expect(wrapper.find('[data-tour="key-form-provider"]').exists()).toBe(false)
expect(optionIds(wrapper)).toHaveLength(11)
})
})
})