mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f93c28ee | ||
|
|
30ed40a56a | ||
|
|
b8275209e8 | ||
|
|
25c21aedda | ||
|
|
2d9af8592f | ||
|
|
8447bdd368 | ||
|
|
32682a4f84 | ||
|
|
55a95d4c67 | ||
|
|
a939553c87 | ||
|
|
badfad8b72 | ||
|
|
1be482c313 | ||
|
|
087987070b | ||
|
|
db76cc4e4e | ||
|
|
c0c4ce1ce6 | ||
|
|
e1eff57c25 |
@@ -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
@@ -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
@@ -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(`{
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '选择状态',
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user