Compare commits

...
Author SHA1 Message Date
Wesley Liddick aea725f2ea Merge pull request #7247 from Qiaogun/codex/seedance-native-api
feat: 支持 Seedance / Ark 原生视频任务 API
2026-09-19 12:11:17 +08:00
Wesley Liddick 74431ffad8 Merge pull request #7258 from HuntercodeT/fix/antigravity-gemini-bare-model-thinking-variant
fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
2026-09-19 12:01:22 +08:00
Wesley Liddick 0c9e83b59c Merge pull request #7278 from HuntercodeT/fix/antigravity-gemini-sse-comment-keepalive
fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
2026-09-19 12:00:18 +08:00
Wesley Liddick 09f88865e0 Merge pull request #7294 from feeeei/feat/model-plaza-icon
ui(header): 手机竖屏顶栏保留模型广场图标入口
2026-09-19 11:59:46 +08:00
Wesley Liddick 1b45d21328 Merge pull request #7340 from ye4241/fix/kimi-cn-quota-403-rate-limit-pause
fix(ratelimit): CN coding-plan 账号配额耗尽的 403 按限流暂停调度,而非永久禁用
2026-09-19 11:59:21 +08:00
Wesley Liddick d5cea617a5 Merge pull request #7345 from laoxiechuzheng/fix/anthropic-root-union-schema
fix(apicompat): 摊平 Responses→Anthropic 工具 schema 顶层联合,修复 Codex 内置工具导致的 400
2026-09-19 11:59:00 +08:00
Wesley Liddick 4275047d16 Merge pull request #7313 from baryon/contrib/deepseek-chat-reasoning-content
fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
2026-09-19 11:57:55 +08:00
Wesley Liddick 4dbcdce43c Merge pull request #7351 from Wei-Shaw/feat/plugin-host-services-status-bridge
feat(plugin): generic host services + read-only status bridge channel
2026-09-19 11:57:41 +08:00
laoxiechuzheng a8a1a3a958 fix(apicompat): flatten root union tool schemas for Anthropic
Codex 会把 codex_app 等内置工具的 parameters 根节点声明成对象分支的 oneOf/anyOf,Anthropic 拒绝工具 input_schema 顶层的 oneOf/anyOf/allOf,导致 /v1/responses -> /v1/messages 直接 400。

Responses->Anthropic 转换时摊平顶层联合:属性取各分支并集,同名属性用嵌套联合保留两侧约束,required 在 oneOf/anyOf 下取分支交集、allOf 下取并集;嵌套联合保持不变。
2026-09-19 07:34:21 +08:00
ye4241andClaude Opus 4.8 db8692d679 fix(ratelimit): pause CN coding-plan accounts on quota-exhausted 403
Kimi reports Coding Plan window exhaustion (5h/weekly) as a 403 with
error.type=access_terminated_error instead of 429. It fell into the
generic handleOpenAI403 escalation, so three 403s within 180 minutes
permanently disabled accounts that recover by themselves at the window
reset.

Detect the quota-exhausted 403 for CN coding-plan accounts and cool it
down exactly like the 429 path: SetRateLimited until the earliest future
window reset from the quota snapshot, falling back to a short
temp-unschedulable window when the snapshot is missing. Non-coding-plan
accounts keep the generic 403 escalation since the rolling-window
recovery mechanism does not apply to them.

Also extract cooldownCNProviderToQuotaSnapshotReset and
setCNProviderTempUnschedulable so the 429 (OpenCodeGo / Coding Plan)
and 403 branches stop carrying three copies of the same sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-19 00:00:57 +08:00
Long LiandCursor bcc73f8d40 fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
Codex histories often replay encrypted-only reasoning items. After a
Responses→Chat Completions fallback, a cache miss left assistant
messages without reasoning_content and DeepSeek rejected the turn
with 400. Fill empty assistant fields with a single-space placeholder
for DeepSeek hosts, including openai-platform accounts whose base_url
points at api.deepseek.com. Existing plaintext and cache hits are
left unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:42:22 +09:00
feeeei a9c7c6e8bf ui(header): 手机竖屏顶栏保留模型广场图标入口
模型广场入口所有宽度都渲染,<640px 只保留图标,补 title/aria-label
2026-09-18 09:52:40 +08:00
HuntercodeT 0f4d8acaa8 fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
Antigravity CLI / go-genai 发裸 Gemini 模型名(gemini-3.8-flash)并用
generationConfig.thinkingConfig 表达思考深度,而 Antigravity 上游目录只有
-low/-medium/-high/-tiered 变体。v0.2.5 起 resolveModelMapping 会给 antigravity
账号运行时补 gemini-3.x-flash 裸名自映射,裸名因此被当成显式透传直发上游,
上游回 404 "Requested entity was not found."。

在 ForwardGemini 取映射之前加 resolveGeminiThinkingVariant:
- 仅对 gemini- 前缀且不带后缀的裸名生效;
- 用户在 credentials.model_mapping 亲写的裸名条目一律不介入,运行时注入的
  默认自映射不算用户意图;
- thinkingLevel 优先;否则 thinkingBudget <0→high,≤1024→low,≤8192→medium,
  其余 high;未携带 thinkingConfig→high;所选变体不存在时按
  high→medium→low→tiered 降级;一个变体都没有则回退原逻辑;
- thinkingConfig 仍原样透传,上游按 budget 决定实际思考量。
2026-09-18 09:03:40 +08:00
HuntercodeT f79b8bf96c fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
Antigravity 账号的 Gemini 原生流式路径在上游空闲超过 gateway.stream_keepalive_interval
(默认 10s)时会向下游写 ":\n\n" 作为心跳。SSE 规范允许注释行,但 Google 官方 SDK 里
go-genai 与 python-genai 都不会忽略它:

- go-genai(Antigravity CLI 在用)api_client.go iterateResponseStream 对任何非 data: 前缀的
  事件直接返回 "iterateResponseStream: invalid stream chunk: :",整个流中断;
- python-genai _api_client.py 把非 data: 行当作错误 JSON 拼接后 json.loads,同样抛错。

Google 官方 API 从不下发注释行,这两个 SDK 从未处理过这种输入。js-genai 用规范的
event-stream 解析器,不受影响。

现在按下游 User-Agent / X-Goog-Api-Client 识别这两类 SDK(形如
google-genai-sdk/1.71.0 gl-go/go1.28 …、google-genai-sdk/x gl-python/3.12),命中时该路径
不再发注释心跳;其他客户端(curl、js-genai、Gemini CLI)行为不变。

生产复现:Antigravity CLI 1.2.5 经网关(sub2api 0.2.5)跑长思考请求,上游首个 chunk 超过 10s
时客户端报 "agent executor error: … iterateResponseStream: invalid stream chunk: :"。
2026-09-17 19:41:55 +08:00
Cai d099c06945 fix: resolve Seedance lint and gRPC security checks 2026-09-17 17:51:03 +09:00
Cai aba34524f1 feat: support native Seedance Ark video task API 2026-09-17 04:14:14 +09:00
37 changed files with 2030 additions and 71 deletions
+3
View File
@@ -29,6 +29,7 @@ const (
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
EndpointVideos = "/v1/videos"
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
EndpointGeminiModels = "/v1beta/models"
)
@@ -81,6 +82,8 @@ const (
func NormalizeInboundEndpoint(path string) string {
path = strings.TrimSpace(path)
switch {
case strings.Contains(path, "/contents/generations/tasks"):
return EndpointSeedanceTasks
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
return EndpointResponsesInputTokens
case strings.Contains(path, EndpointEmbeddings):
+34 -11
View File
@@ -52,6 +52,12 @@ func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) {
}
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
platform := service.PlatformGrok
noAccountCode, noAccountMessage := "grok_media_no_eligible_account", "No eligible Grok media accounts"
if endpoint.IsSeedance() {
platform = service.PlatformOpenAI
noAccountCode, noAccountMessage = "seedance_no_eligible_account", "No eligible Seedance accounts"
}
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
@@ -99,6 +105,13 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
contentType := c.GetHeader("Content-Type")
requestInfo := service.ParseGrokMediaRequest(contentType, body)
if endpoint == service.SeedanceEndpointCreate {
requestInfo, err = service.ParseSeedanceRequest(body)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
}
requestModel := requestInfo.Model
routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage())
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
@@ -215,8 +228,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
var selection *service.AccountSelectionResult
var scheduleDecision service.OpenAIAccountScheduleDecision
if boundLookupAccountID > 0 {
selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel,
selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform,
)
} else {
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
@@ -231,7 +244,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
false,
false,
false,
service.PlatformGrok,
platform,
)
}
// Own an eagerly acquired slot before any rejection or eligibility probe.
@@ -256,11 +269,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) &&
(len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
return
}
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -277,10 +290,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if selection == nil || selection.Account == nil {
if endpoint.IsGenerationRequest() {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
return
}
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
@@ -309,7 +322,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
)
account := selection.Account
if endpoint.IsGenerationRequest() {
if endpoint.IsGenerationRequest() && !endpoint.IsSeedance() {
eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account)
if !eligible {
releaseAccount()
@@ -360,6 +373,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
defer releaseAccount()
if endpoint.IsSeedance() {
return h.gatewayService.ForwardSeedance(requestCtx, c, account, endpoint, requestID, body)
}
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
@@ -490,7 +506,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
// Status poll OR content download can observe official done+video.url.
// Both paths share the same claim key so the customer is charged once.
if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
if endpoint == service.SeedanceEndpointStatus {
if billResult := prepareSeedanceCompletionBilling(requestCtx, h, apiKey, subject, requestID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID)
}
} else if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
taskID := strings.TrimSpace(requestID)
if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID)
@@ -521,6 +541,9 @@ func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Con
}
func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability {
if endpoint.IsSeedance() {
return service.OpenAIEndpointCapabilitySeedance
}
if endpoint.IsGenerationRequest() {
return service.OpenAIEndpointCapabilityGrokMediaGeneration
}
@@ -539,7 +562,7 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
switch endpoint {
case service.GrokMediaEndpointVideosGenerations,
case service.SeedanceEndpointCreate, service.GrokMediaEndpointVideosGenerations,
service.GrokMediaEndpointVideosEdits,
service.GrokMediaEndpointVideosExtensions:
return true
@@ -712,7 +735,7 @@ func recordGrokMediaUsage(
}
// Async video: force durable task request id and release claim if billing fails.
videoTaskID := ""
if result != nil && result.VideoCount > 0 {
if result != nil && (result.VideoCount > 0 || strings.HasPrefix(result.ResponseID, "seedance:")) {
videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID))
if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" {
result.RequestID = stable
@@ -120,10 +120,22 @@ func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
type grokMediaSlotBindings struct {
testutil.StubGatewayCache
owner int64
writes int
key string
billed map[string]bool
owner int64
writes int
key string
billed map[string]bool
pending map[string][]byte
}
func (s *grokMediaSlotBindings) SetGrokVideoPendingBilling(_ context.Context, key string, body []byte, _ time.Duration) error {
if s.pending == nil {
s.pending = make(map[string][]byte)
}
s.pending[key] = append([]byte(nil), body...)
return nil
}
func (s *grokMediaSlotBindings) GetGrokVideoPendingBilling(_ context.Context, key string) ([]byte, error) {
return s.pending[key], nil
}
func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) {
@@ -186,7 +198,7 @@ func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64
return p(ctx, id)
}
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool, platforms ...string) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
t.Helper()
gin.SetMode(gin.TestMode)
accounts := make([]service.Account, 3)
@@ -194,6 +206,11 @@ func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGateway
accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i,
GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}}
if len(platforms) > 0 {
accounts[i].Platform = platforms[0]
accounts[i].Credentials["base_url"] = "https://ark.cn-beijing.volces.com/api/v3"
accounts[i].Credentials["openai_capabilities"] = []string{"seedance"}
}
if oauth {
accounts[i].Type = service.AccountTypeOAuth
accounts[i].Credentials["refresh_token"] = "test-refresh"
+63
View File
@@ -0,0 +1,63 @@
package handler
import (
"context"
"mime"
"net/http"
"time"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// SeedanceTasks exposes Ark's native asynchronous video task protocol.
func (h *OpenAIGatewayHandler) SeedanceTasks(c *gin.Context) {
if c.Request.Method == http.MethodPost && c.GetHeader("Content-Type") != "" {
mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
if err != nil || mediaType != "application/json" {
h.errorResponse(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Seedance requires application/json")
return
}
}
key, ok := middleware.GetAPIKeyFromContext(c)
if !ok || key.Group == nil || (key.Group.Platform != service.PlatformOpenAI && key.Group.Platform != service.PlatformComposite) {
h.errorResponse(c, http.StatusForbidden, "permission_error", "Seedance requires an OpenAI or composite group")
return
}
endpoint := service.SeedanceEndpointCreate
setActualUpstreamEndpoint(c, EndpointSeedanceTasks)
taskID := ""
if c.Request.Method != http.MethodPost {
taskID = service.SeedanceTaskKey(c.Param("task_id"))
endpoint = service.SeedanceEndpointStatus
if c.Request.Method == http.MethodDelete {
endpoint = service.SeedanceEndpointDelete
}
}
h.handleGrokMedia(c, endpoint, taskID)
}
// Ark reports actual completion tokens. Never infer tokens from duration or use
// Grok's per-second video tariff. Repeated polls share the durable task dedup key.
func prepareSeedanceCompletionBilling(ctx context.Context, h *OpenAIGatewayHandler, key *service.APIKey, subject middleware.AuthSubject, taskID string, result *service.OpenAIForwardResult) *service.OpenAIForwardResult {
if result == nil || result.Usage.OutputTokens <= 0 {
return nil
}
pending, err := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskID, subject.UserID, key.ID)
if err != nil || pending == nil {
return nil
}
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskID, subject.UserID, key.ID)
if err != nil || !claimed {
return nil
}
merged := *result
merged.Model = pending.Model
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model)
merged.UpstreamModel = firstNonEmptyString(pending.UpstreamModel, result.UpstreamModel)
merged.RequestID = service.StableGrokVideoBillingRequestID(taskID)
merged.ResponseID = taskID
merged.Duration = service.GrokVideoE2EDuration(pending.CreatedAt, time.Now())
return &merged
}
+97
View File
@@ -0,0 +1,97 @@
//go:build unit
package handler
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSeedanceHandlerLifecycleAndOwnership(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false, service.PlatformOpenAI)
var owner int64
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
body := `{"id":"task-ark","status":"queued"}`
if req.Method == http.MethodPost {
owner = id
} else {
require.Equal(t, owner, id)
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
}
newContext := func(method string) (*gin.Context, *httptest.ResponseRecorder) {
c, w := grokMediaSlotContext(context.Background(), method == http.MethodPost)
key, _ := middleware.GetAPIKeyFromContext(c)
key.Group.Platform = service.PlatformOpenAI
body := ""
if method == http.MethodPost {
body = `{"model":"doubao-seedance","content":[{"type":"text","text":"waves"}]}`
}
c.Request = httptest.NewRequest(method, "/api/v3/contents/generations/tasks", strings.NewReader(body))
c.Params = gin.Params{{Key: "task_id", Value: "task-ark"}}
return c, w
}
c, w := newContext(http.MethodPost)
h.SeedanceTasks(c)
require.Equal(t, 200, w.Code, w.Body.String())
require.Positive(t, owner)
require.Len(t, bindings.pending, 1)
slots.assertReleased(t)
for _, method := range []string{http.MethodGet, http.MethodDelete} {
c, w = newContext(method)
h.SeedanceTasks(c)
require.Equal(t, 200, w.Code, w.Body.String())
slots.assertReleased(t)
}
for _, other := range []string{"user", "key", "group", "task", "provider"} {
c, w = newContext(http.MethodGet)
key, _ := middleware.GetAPIKeyFromContext(c)
switch other {
case "user":
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 11, Concurrency: 5})
case "key":
key.ID = 21
case "group":
group := int64(25)
key.GroupID = &group
case "task":
c.Params = gin.Params{{Key: "task_id", Value: "other"}}
case "provider":
c.Params = gin.Params{{Key: "request_id", Value: "task-ark"}}
}
before := upstream.calls
if other == "provider" {
h.GrokVideoStatus(c)
} else {
h.SeedanceTasks(c)
}
require.Equal(t, 404, w.Code, other+": "+w.Body.String())
require.Equal(t, before, upstream.calls)
slots.assertReleased(t)
}
c, _ = newContext(http.MethodGet)
key, _ := middleware.GetAPIKeyFromContext(c)
subject, _ := middleware.GetAuthSubjectFromContext(c)
result := &service.OpenAIForwardResult{Usage: service.OpenAIUsage{OutputTokens: 12345}, ResponseID: "seedance:task-ark"}
for i := range 20 {
billed := prepareSeedanceCompletionBilling(context.Background(), h, key, subject, result.ResponseID, result)
if i == 0 {
require.NotNil(t, billed)
require.Equal(t, "doubao-seedance", billed.BillingModel)
require.Equal(t, 12345, billed.Usage.OutputTokens)
require.Zero(t, billed.VideoCount)
} else {
require.Nil(t, billed)
}
}
require.Len(t, bindings.billed, 1)
}
@@ -646,6 +646,9 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
}
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
// Codex 会把部分内置工具(例如 codex_app 的 automation_update)的 parameters
// 根节点声明成对象分支的 oneOf/anyOf,Anthropic 只接受 object 根节点,这里把
// 顶层联合摊平成单个 object schema。
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
const emptyObjectSchema = `{"type":"object","properties":{}}`
@@ -659,6 +662,8 @@ func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
flattenAnthropicRootUnions(m)
typeRaw, ok := m["type"]
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
m["type"] = json.RawMessage(`"object"`)
@@ -0,0 +1,213 @@
package apicompat
import (
"bytes"
"encoding/json"
)
// anthropicInputSchemaUnionKeywords 是 Anthropic 在工具 input_schema 顶层拒绝的
// JSON Schema 联合关键字;嵌套层不受限制,因此只摊平根节点。
var anthropicInputSchemaUnionKeywords = []string{"oneOf", "anyOf", "allOf"}
// flattenAnthropicRootUnions 用单一 object schema 替换顶层的 oneOf/anyOf/allOf:
// 属性取各分支并集,同名属性用嵌套联合保留两侧约束;required 在 oneOf/anyOf
// 下取分支交集、在 allOf 下取分支并集。非对象分支无法用 Anthropic 的 object
// schema 表达,视为不贡献属性与必填字段。
func flattenAnthropicRootUnions(schema map[string]json.RawMessage) {
present := make([]string, 0, len(anthropicInputSchemaUnionKeywords))
for _, keyword := range anthropicInputSchemaUnionKeywords {
if _, ok := schema[keyword]; ok {
present = append(present, keyword)
}
}
if len(present) == 0 {
return
}
properties := anthropicSchemaProperties(schema)
rootRequired := anthropicBranchRequired(schema)
var (
intersections [][]string
allOfRequired []string
)
for _, keyword := range present {
raw := schema[keyword]
delete(schema, keyword)
branches, ok := decodeAnthropicUnionBranches(raw)
if !ok {
continue
}
mergeAnthropicObjectBranchProperties(properties, branches)
if keyword == "allOf" {
for _, branch := range branches {
allOfRequired = appendAnthropicRequired(allOfRequired, anthropicBranchRequired(branch))
}
continue
}
intersections = append(intersections, intersectAnthropicRequired(branches))
}
if len(properties) > 0 {
if encoded, err := json.Marshal(properties); err == nil {
schema["properties"] = encoded
}
}
required := intersectAnthropicRequiredLists(intersections)
required = appendAnthropicRequired(required, allOfRequired)
required = appendAnthropicRequired(required, rootRequired)
if len(required) > 0 {
if encoded, err := json.Marshal(required); err == nil {
schema["required"] = encoded
}
}
}
func anthropicSchemaProperties(schema map[string]json.RawMessage) map[string]json.RawMessage {
properties := make(map[string]json.RawMessage)
raw, ok := schema["properties"]
if !ok {
return properties
}
if err := json.Unmarshal(raw, &properties); err != nil || properties == nil {
return make(map[string]json.RawMessage)
}
return properties
}
// decodeAnthropicUnionBranches 解析联合分支并递归摊平分支自身的顶层联合。
// 非对象 JSON 分支以 nil 占位:它不贡献属性,也不要求任何字段。
func decodeAnthropicUnionBranches(raw json.RawMessage) ([]map[string]json.RawMessage, bool) {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
return nil, false
}
branches := make([]map[string]json.RawMessage, 0, len(items))
for _, item := range items {
var branch map[string]json.RawMessage
if err := json.Unmarshal(item, &branch); err != nil || branch == nil {
branches = append(branches, nil)
continue
}
flattenAnthropicRootUnions(branch)
branches = append(branches, branch)
}
return branches, true
}
func mergeAnthropicObjectBranchProperties(into map[string]json.RawMessage, branches []map[string]json.RawMessage) {
for _, branch := range branches {
if !anthropicSchemaBranchIsObject(branch) {
continue
}
raw, ok := branch["properties"]
if !ok {
continue
}
var properties map[string]json.RawMessage
if err := json.Unmarshal(raw, &properties); err != nil {
continue
}
for name, property := range properties {
existing, seen := into[name]
if !seen {
into[name] = property
continue
}
if anthropicSchemaJSONEqual(existing, property) {
continue
}
merged, err := json.Marshal(map[string][]json.RawMessage{"anyOf": {existing, property}})
if err != nil {
continue
}
into[name] = merged
}
}
}
// anthropicSchemaBranchIsObject 判断分支是否可能是对象:显式 type=object,或省略
// type 但声明了 properties(缺失 type 等价于不约束,不能据此排除对象分支)。
func anthropicSchemaBranchIsObject(branch map[string]json.RawMessage) bool {
if branch == nil {
return false
}
if raw, ok := branch["type"]; ok {
var typ string
return json.Unmarshal(raw, &typ) == nil && typ == "object"
}
_, ok := branch["properties"]
return ok
}
func anthropicBranchRequired(branch map[string]json.RawMessage) []string {
if branch == nil {
return nil
}
raw, ok := branch["required"]
if !ok {
return nil
}
var required []string
if err := json.Unmarshal(raw, &required); err != nil {
return nil
}
return required
}
// intersectAnthropicRequired 返回所有分支共同要求的字段,顺序沿用第一个分支。
func intersectAnthropicRequired(branches []map[string]json.RawMessage) []string {
lists := make([][]string, 0, len(branches))
for _, branch := range branches {
lists = append(lists, anthropicBranchRequired(branch))
}
return intersectAnthropicRequiredLists(lists)
}
func intersectAnthropicRequiredLists(lists [][]string) []string {
if len(lists) == 0 {
return nil
}
result := lists[0]
for _, list := range lists[1:] {
if len(result) == 0 {
return nil
}
next := make([]string, 0, len(result))
for _, name := range result {
if containsAnthropicName(list, name) {
next = append(next, name)
}
}
result = next
}
return result
}
func appendAnthropicRequired(required []string, names []string) []string {
merged := make([]string, 0, len(required))
merged = append(merged, required...)
for _, name := range names {
if !containsAnthropicName(merged, name) {
merged = append(merged, name)
}
}
return merged
}
func containsAnthropicName(names []string, target string) bool {
for _, name := range names {
if name == target {
return true
}
}
return false
}
func anthropicSchemaJSONEqual(left, right json.RawMessage) bool {
var leftBuf, rightBuf bytes.Buffer
if json.Compact(&leftBuf, left) != nil || json.Compact(&rightBuf, right) != nil {
return false
}
return leftBuf.String() == rightBuf.String()
}
@@ -138,3 +138,180 @@ func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
assert.Equal(t, "shell", tools[0].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
}
// Codex 的 codex_app 命名空间工具(如 automation_update)把 parameters 根节点声明为
// 对象分支的 oneOf/anyOf;Anthropic 拒绝 input_schema 顶层的 oneOf/anyOf/allOf,
// 转换时必须摊平成单个 object schema。
func TestResponsesToAnthropic_ObjectUnionRootFlattened(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "codex_app__automation_update",
Parameters: json.RawMessage(`{
"oneOf": [
{"type":"object","properties":{"id":{"type":"string"}}},
{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
assert.NotContains(t, schema, "anyOf")
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `{"id":{"type":"string"}}`, string(schema["properties"]))
assert.NotContains(t, schema, "required")
}
func TestResponsesToAnthropic_ObjectUnionRootMergesBranches(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "codex_app__automation_update",
Parameters: json.RawMessage(`{
"anyOf": [
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
{"type":"object","properties":{"mode":{"enum":["update"]},"prompt":{"type":"string"}},"required":["mode","prompt"]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "anyOf")
// 属性取各分支并集;同名属性保留两侧约束(嵌套联合本身是允许的)。
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
require.Contains(t, properties, "id")
require.Contains(t, properties, "prompt")
var mode map[string]json.RawMessage
require.NoError(t, json.Unmarshal(properties["mode"], &mode))
assert.Contains(t, mode, "anyOf")
// required 取各分支交集,只保留所有分支都要求的字段。
assert.JSONEq(t, `["mode"]`, string(schema["required"]))
}
func TestResponsesToAnthropic_AllOfRootKeepsUnionRequired(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "strict_tool",
Parameters: json.RawMessage(`{
"allOf": [
{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},
{"type":"object","properties":{"encoding":{"type":"string"}},"required":["encoding"]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `["path","encoding"]`, string(schema["required"]))
}
// 非对象分支无法用 Anthropic 的 object schema 表达,直接丢弃分支但保留属性词汇。
func TestResponsesToAnthropic_RootUnionDropsNonObjectBranches(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "mixed_tool",
Parameters: json.RawMessage(`{"oneOf":[{"type":"object","properties":{"path":{"type":"string"}}},{"type":"string"}]}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
assert.Contains(t, properties, "path")
}
func TestResponsesToAnthropic_NestedUnionPreserved(t *testing.T) {
parameters := json.RawMessage(`{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}}}`)
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "get_value",
Parameters: parameters,
}})
require.Len(t, tools, 1)
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
}
// 根节点自带 properties/required 时,与分支约束合并而不是被覆盖。
func TestResponsesToAnthropic_RootUnionKeepsRootPropertiesAndRequired(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "merge_root_tool",
Parameters: json.RawMessage(`{
"type":"object",
"properties":{"shared":{"type":"string"}},
"required":["shared"],
"oneOf":[{"type":"object","properties":{"extra":{"type":"integer"}}}]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
assert.Contains(t, properties, "shared")
assert.Contains(t, properties, "extra")
assert.JSONEq(t, `["shared"]`, string(schema["required"]))
}
// 端到端:Codex 命名空间工具经降级/摊平后,最终 Anthropic 工具 schema 顶部不再有联合关键字。
func TestResponsesToAnthropic_CodexNamespaceRootUnionTool(t *testing.T) {
body := []byte(`{
"model": "claude-opus-5",
"input": "create an automation",
"tools": [{
"type": "namespace",
"name": "codex_app",
"tools": [{
"type": "function",
"name": "automation_update",
"description": "Create, update, view, or delete recurring automations",
"parameters": {
"oneOf": [
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
{"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]}
]
}
}]
}]
}`)
var requestBody map[string]any
require.NoError(t, json.Unmarshal(body, &requestBody))
_, changed, err := AdaptResponsesClientTools(requestBody)
require.NoError(t, err)
require.True(t, changed)
adapted, err := json.Marshal(requestBody)
require.NoError(t, err)
var responsesReq ResponsesRequest
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
anthropicReq, err := ResponsesToAnthropicRequest(&responsesReq)
require.NoError(t, err)
require.Len(t, anthropicReq.Tools, 1)
tool := anthropicReq.Tools[0]
assert.Equal(t, "codex_app__automation_update", tool.Name)
schema := requireObjectInputSchema(t, tool.InputSchema)
assert.NotContains(t, schema, "oneOf")
assert.NotContains(t, schema, "anyOf")
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `["mode","id"]`, string(schema["required"]))
wire, err := json.Marshal(tool)
require.NoError(t, err)
assert.NotContains(t, string(wire), `"oneOf"`)
assert.NotContains(t, string(wire), `"allOf"`)
// 同名属性两侧的约束用嵌套联合保留,只允许出现在 properties 内部。
assert.Contains(t, string(wire), `"mode":{"anyOf":[{"enum":["view"]},{"enum":["update"]}]}`)
}
@@ -368,6 +368,11 @@ func RegisterGatewayRoutes(
rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) {
r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)
}
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks)
rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
}
rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler)
rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler))
rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
@@ -157,6 +157,10 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) {
{http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
{http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/api/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v1/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
{http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
@@ -0,0 +1,34 @@
package routes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestSeedanceNativeRoutes(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} {
path := prefix + "/contents/generations/tasks"
if method != http.MethodPost {
path += "/task-1"
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(`{"model":"seedance","content":[{"type":"text","text":"waves"}]}`)))
require.NotEqual(t, http.StatusNotFound, w.Code, method+" "+path)
}
}
}
func TestSeedanceRejectsOtherPlatforms(t *testing.T) {
for _, platform := range []string{service.PlatformGrok, service.PlatformAnthropic, service.PlatformGemini} {
w := httptest.NewRecorder()
newGatewayRoutesTestRouter(platform).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{"model":"seedance","content":[{}]}`)))
require.Equal(t, http.StatusForbidden, w.Code)
}
}
+5
View File
@@ -1828,6 +1828,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
if a == nil {
return false
}
if capability == OpenAIEndpointCapabilitySeedance {
configured, _ := a.openAIEndpointCapabilitySet()
return configured["seedance"] && a.Platform == PlatformOpenAI && a.Type == AccountTypeAPIKey &&
strings.TrimSpace(a.GetCredential("base_url")) != ""
}
if capability == "" {
return true
}
@@ -86,7 +86,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
}
mappedModel := s.getMappedModel(account, originalModel)
// 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体;
// 裸名有显式映射时保持原行为。
mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body)
if !variantResolved {
mappedModel = s.getMappedModel(account, originalModel)
} else {
logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel)
}
if mappedModel == "" {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
@@ -197,6 +197,11 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 {
keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
}
// go-genai / python-genai 不会忽略 SSE 注释行,收到 ":\n\n" 会直接把整个流判成
// invalid stream chunk 而中断(Antigravity CLI 在用 go-genai)。对这类客户端宁可不发心跳。
if keepaliveInterval > 0 && downstreamRejectsSSEComments(c) {
keepaliveInterval = 0
}
var keepaliveTicker *time.Ticker
if keepaliveInterval > 0 {
keepaliveTicker = time.NewTicker(keepaliveInterval)
@@ -0,0 +1,137 @@
package service
import (
"encoding/json"
"strings"
)
// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时,
// 客户端(如 Antigravity CLI / go-genai)习惯发"裸"模型名(gemini-3.8-flash)并用
// generationConfig.thinkingConfig 表达思考深度;而 Antigravity 上游的模型目录只有
// gemini-3.8-flash-low / -medium / -high / -tiered 这类带后缀的变体,裸名直接转发会被上游
// 以 404 "Requested entity was not found." 拒绝。
//
// 这里在账号 model_mapping 没有为裸名配置显式条目、但配置了它的后缀变体时,
// 按 thinkingConfig 自动挑一个变体:
// - thinkingLevel: "low" / "medium" / "high" → 同名后缀
// - thinkingBudget: -1(动态)→ high;0 → low;1..1024 → low;1025..8192 → medium;>8192 → high
// - 未携带 thinkingConfig → high(与 Gemini 3 系列默认开启动态思考一致)
// 选中的后缀在映射表里不存在时按 high → medium → low → tiered 的顺序降级到存在的变体。
// 显式映射(裸名本身在 model_mapping 里)始终优先,行为与现在完全一致。
var geminiThinkingVariantSuffixes = []string{"-low", "-medium", "-high", "-tiered"}
const (
geminiThinkingBudgetLowMax = 1024
geminiThinkingBudgetMediumMax = 8192
)
type geminiThinkingConfigProbe struct {
GenerationConfig struct {
ThinkingConfig *struct {
ThinkingBudget *json.Number `json:"thinkingBudget"`
ThinkingLevel string `json:"thinkingLevel"`
} `json:"thinkingConfig"`
} `json:"generationConfig"`
}
// hasGeminiThinkingVariantSuffix 判断模型名是否已经带了思考深度后缀。
func hasGeminiThinkingVariantSuffix(model string) bool {
for _, suffix := range geminiThinkingVariantSuffixes {
if strings.HasSuffix(model, suffix) {
return true
}
}
return false
}
// geminiThinkingLevelFromBody 从 Gemini 原生请求体推导期望的思考档位(low/medium/high)。
// 解析失败或未携带 thinkingConfig 时返回 "high"。
func geminiThinkingLevelFromBody(body []byte) string {
if len(body) == 0 {
return "high"
}
var probe geminiThinkingConfigProbe
if err := json.Unmarshal(body, &probe); err != nil || probe.GenerationConfig.ThinkingConfig == nil {
return "high"
}
tc := probe.GenerationConfig.ThinkingConfig
switch strings.ToLower(strings.TrimSpace(tc.ThinkingLevel)) {
case "low":
return "low"
case "medium":
return "medium"
case "high":
return "high"
}
if tc.ThinkingBudget == nil {
return "high"
}
budget, err := tc.ThinkingBudget.Float64()
if err != nil {
return "high"
}
switch {
case budget < 0:
return "high" // -1 = 动态思考
case budget <= geminiThinkingBudgetLowMax:
return "low" // 含 0(关闭思考):上游没有"无思考"变体,取最浅档,budget 本身仍原样透传
case budget <= geminiThinkingBudgetMediumMax:
return "medium"
default:
return "high"
}
}
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
func accountRawModelMappingHasKey(account *Account, key string) bool {
if account == nil || account.Credentials == nil {
return false
}
raw, _ := account.Credentials["model_mapping"].(map[string]any)
_, ok := raw[key]
return ok
}
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
if account == nil {
return "", false
}
model := strings.TrimSpace(strings.TrimPrefix(requestedModel, "models/"))
if !strings.HasPrefix(model, "gemini-") || hasGeminiThinkingVariantSuffix(model) {
return "", false
}
mapping := account.GetModelMapping()
if len(mapping) == 0 {
return "", false
}
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
// 正是导致 404 的来源,不能算作用户意图。
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
return "", false
}
}
preferred := geminiThinkingLevelFromBody(body)
order := []string{preferred}
for _, level := range []string{"high", "medium", "low", "tiered"} {
if level != preferred {
order = append(order, level)
}
}
for _, level := range order {
candidate := model + "-" + level
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
return mapped, true
}
}
return "", false
}
@@ -0,0 +1,118 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func newAntigravityAccountWithMapping(mapping map[string]string) *Account {
mappingAny := make(map[string]any, len(mapping))
for k, v := range mapping {
mappingAny[k] = v
}
return &Account{
ID: 1,
Name: "ag",
Platform: PlatformAntigravity,
Credentials: map[string]any{
"model_mapping": mappingAny,
},
}
}
func TestGeminiThinkingLevelFromBody(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{"empty body", ``, "high"},
{"no thinkingConfig", `{"contents":[]}`, "high"},
{"budget -1 dynamic", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, "high"},
{"budget 0 off", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, "low"},
{"budget 1000 (agy low)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":1000}}}`, "low"},
{"budget 4000 (agy medium)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`, "medium"},
{"budget 24576", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, "high"},
{"thinkingLevel wins over budget", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576,"thinkingLevel":"low"}}}`, "low"},
{"thinkingLevel medium", `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"MEDIUM"}}}`, "medium"},
{"invalid json", `{"generationConfig":`, "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, geminiThinkingLevelFromBody([]byte(tt.body)))
})
}
}
func TestResolveGeminiThinkingVariant(t *testing.T) {
// 与生产 Antigravity 账号从上游同步来的目录一致:只有带后缀的变体,没有裸名。
catalog := map[string]string{
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
"gemini-2.5-flash": "gemini-2.5-flash",
}
budget := func(v string) []byte {
return []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"thinkingBudget":` + v + `}}}`)
}
tests := []struct {
name string
mapping map[string]string
model string
body []byte
want string
wantMatch bool
}{
{"agy high (-1) -> -high", catalog, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
{"agy medium (4000) -> -medium", catalog, "gemini-3.8-flash", budget("4000"), "gemini-3.8-flash-medium", true},
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
{"only -high exists: low request degrades to high", map[string]string{
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
{"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true},
{"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false},
{"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false},
{"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false},
{"non-gemini model: untouched", catalog, "claude-sonnet-4-6", budget("-1"), "", false},
{"explicit bare alias wins over variants", map[string]string{
"gemini-3.8-flash": "gemini-3.8-flash-tiered",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
}, "gemini-3.8-flash", budget("-1"), "", false},
{"variant value is itself a mapping target", map[string]string{
"gemini-3.8-flash-high": "gemini-3.8-flash-tiered",
}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true},
{"user-written bare identity passthrough: respected", map[string]string{
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
}, "gemini-3.8-flash", budget("1000"), "", false},
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
// 但 credentials 里没有这条,应当继续推导变体。
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := newAntigravityAccountWithMapping(tt.mapping)
got, matched := resolveGeminiThinkingVariant(account, tt.model, tt.body)
require.Equal(t, tt.wantMatch, matched)
require.Equal(t, tt.want, got)
})
}
t.Run("nil account", func(t *testing.T) {
got, matched := resolveGeminiThinkingVariant(nil, "gemini-3.8-flash", budget("-1"))
require.False(t, matched)
require.Empty(t, got)
})
}
@@ -74,6 +74,53 @@ func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T
require.Equal(t, "message", input[0].(map[string]any)["type"])
}
// Codex 的 codex_app 工具(如 automation_update)把 parameters 根节点声明成对象
// 联合;Responses→Anthropic 转换后 input_schema 顶部不能再出现联合关键字。
func TestAdaptResponsesClientToolsForAnthropic_FlattensRootUnionSchema(t *testing.T) {
t.Parallel()
body := []byte(`{
"model":"claude-opus-5",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"create an automation"}]}],
"tools":[{"type":"namespace","name":"codex_app","tools":[{
"type":"function",
"name":"automation_update",
"description":"Create, update, view, or delete recurring automations",
"parameters":{"oneOf":[
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
{"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]}
]}
}]}]
}`)
adapted, _, err := adaptResponsesClientToolsForAnthropic(body)
require.NoError(t, err)
var responsesReq apicompat.ResponsesRequest
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
claudeReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq)
require.NoError(t, err)
require.Len(t, claudeReq.Tools, 1)
tool := claudeReq.Tools[0]
require.Equal(t, "codex_app__automation_update", tool.Name)
var schema map[string]json.RawMessage
require.NoError(t, json.Unmarshal(tool.InputSchema, &schema))
require.JSONEq(t, `"object"`, string(schema["type"]))
require.NotContains(t, schema, "oneOf")
require.NotContains(t, schema, "anyOf")
require.NotContains(t, schema, "allOf")
require.JSONEq(t, `["mode","id"]`, string(schema["required"]))
wire, err := json.Marshal(tool)
require.NoError(t, err)
require.NotContains(t, string(wire), `"input_schema":{"oneOf"`)
require.NotContains(t, string(wire), `"input_schema":{"anyOf"`)
require.NotContains(t, string(wire), `"input_schema":{"allOf"`)
}
func namespaceToolAnthropicStream() string {
return strings.Join([]string{
`event: message_start`,
@@ -0,0 +1,35 @@
package service
import (
"strings"
"github.com/gin-gonic/gin"
)
// geminiClientRejectsSSEComments 判断下游客户端是否是「无法容忍 SSE 注释行」的 Google GenAI SDK。
//
// Gemini 原生流里我们用 ":\n\n" 作为空闲心跳(防止代理/Cloudflare Tunnel 断开),但官方 SDK 里
// 有两个实现不按 SSE 规范忽略注释行:
// - go-genai(Antigravity CLI 在用):api_client.go iterateResponseStream 对任何非 "data:" 前缀的
// 事件直接报 "invalid stream chunk: :",整个流中断;
// - python-genai:_api_client.py 把非 "data:" 行当作错误 JSON 拼接后 json.loads,同样抛错。
//
// js-genai 用的是规范的 event-stream 解析器,能正确忽略注释。Google 官方 API 从不下发注释行,
// 所以这两个 SDK 从未处理过这种输入。识别依据是 SDK 固定携带的 User-Agent / X-Goog-Api-Client,
// 形如 "google-genai-sdk/1.71.0 gl-go/go1.28 ..." 或 "google-genai-sdk/1.x gl-python/3.12"。
func geminiClientRejectsSSEComments(clientHint string) bool {
hint := strings.ToLower(strings.TrimSpace(clientHint))
if !strings.Contains(hint, "google-genai-sdk/") {
return false
}
return strings.Contains(hint, "gl-go/") || strings.Contains(hint, "gl-python/")
}
// downstreamRejectsSSEComments 从请求头识别下游客户端(同时看 User-Agent 与 X-Goog-Api-Client)。
func downstreamRejectsSSEComments(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
return geminiClientRejectsSSEComments(c.GetHeader("User-Agent")) ||
geminiClientRejectsSSEComments(c.GetHeader("X-Goog-Api-Client"))
}
@@ -0,0 +1,95 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestGeminiClientRejectsSSEComments(t *testing.T) {
cases := []struct {
name string
hint string
want bool
}{
{"go-genai (Antigravity CLI)", "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03 cl/951519500 +3ebc191975 X:fieldtrack,boringcrypto", true},
{"python-genai", "google-genai-sdk/1.20.0 gl-python/3.12.4", true},
{"js-genai tolerates comments", "google-genai-sdk/1.9.0 gl-node/22.3.0", false},
{"gemini-cli", "GeminiCLI/0.60.0 (darwin; arm64)", false},
{"curl", "curl/8.7.1", false},
{"empty", "", false},
{"case-insensitive", "Google-GenAI-SDK/1.0.0 GL-Go/go1.27", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, geminiClientRejectsSSEComments(tc.hint))
})
}
}
func TestDownstreamRejectsSSECommentsReadsBothHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
require.False(t, downstreamRejectsSSEComments(c))
c.Request.Header.Set("X-Goog-Api-Client", "google-genai-sdk/1.71.0 gl-go/go1.28")
require.True(t, downstreamRejectsSSEComments(c))
c.Request.Header.Del("X-Goog-Api-Client")
c.Request.Header.Set("User-Agent", "google-genai-sdk/1.71.0 gl-go/go1.28")
require.True(t, downstreamRejectsSSEComments(c))
require.False(t, downstreamRejectsSSEComments(nil))
}
// runAntigravityGeminiStreamWithIdle 起一条上游流:先发一个 data 事件,然后空闲 idle 时长再关闭,
// 返回写给下游的全部字节。用来观察空闲期间网关是否发了 ":\n\n" 心跳。
func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle time.Duration) string {
t.Helper()
gin.SetMode(gin.TestMode)
svc := newAntigravityCompatService(
config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamKeepaliveInterval: 1},
nil,
)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
if userAgent != "" {
c.Request.Header.Set("User-Agent", userAgent)
}
reader, writer := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
done := make(chan error, 1)
go func() {
_, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
done <- err
}()
_, err := io.WriteString(
writer,
`data: {"response":{"responseId":"resp_1","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`+"\n\n",
)
require.NoError(t, err)
time.Sleep(idle)
require.NoError(t, writer.Close())
require.NoError(t, <-done)
require.NoError(t, reader.Close())
return recorder.Body.String()
}
func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) {
out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond)
require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive")
require.Contains(t, out, `"text":"partial"`)
}
func TestAntigravityGeminiStreamSkipsCommentKeepaliveForGoGenai(t *testing.T) {
out := runAntigravityGeminiStreamWithIdle(t, "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03", 1200*time.Millisecond)
require.Contains(t, out, `"text":"partial"`)
for _, event := range strings.Split(out, "\n\n") {
require.False(t, strings.HasPrefix(event, ":"), "go-genai must never receive an SSE comment event, got %q", event)
}
}
+11 -2
View File
@@ -41,10 +41,13 @@ func (e GrokMediaEndpoint) RequiresRequestBody() bool {
}
func (e GrokMediaEndpoint) IsVideoLookupRequest() bool {
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
}
func (e GrokMediaEndpoint) IsGenerationRequest() bool {
if e == SeedanceEndpointCreate {
return true
}
switch e {
case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions:
return true
@@ -337,6 +340,12 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount(
// the ownership key; video lookups must neither escape nor refresh that key.
func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel string,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
return s.SelectMediaVideoRequestAccount(ctx, groupID, sessionHash, accountID, requestedModel, PlatformGrok)
}
func (s *OpenAIGatewayService) SelectMediaVideoRequestAccount(
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel, platform string,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerSessionSticky}
if accountID <= 0 || strings.TrimSpace(sessionHash) == "" {
@@ -345,7 +354,7 @@ func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
ctx = s.withOpenAIGroupPrivacyRequirement(WithOpenAIProfitControlSuppressed(ctx), groupID)
scheduler := &defaultOpenAIAccountScheduler{service: s}
selection, _, err := scheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{
GroupID: groupID, Platform: PlatformGrok, SessionHash: sessionHash,
GroupID: groupID, Platform: platform, SessionHash: sessionHash,
StickyAccountID: accountID, PreserveStickyBinding: true, DisableStickyEscape: true,
RequestedModel: requestedModel, RequiredTransport: OpenAIUpstreamTransportHTTPSSE,
RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID),
@@ -182,6 +182,11 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
userAgent string,
grokCacheIdentity string,
) (*http.Response, error) {
// DeepSeek thinking mode 要求历史 assistant 回传 reasoning_content。
// Responses→CC 回退在加密-only / 缺 reasoning item 且缓存未命中时会漏掉该
// 字段,上游 400 "The `reasoning_content` in the thinking mode must be
// passed back to the API"。在共用出站点补空格占位,真实明文不覆盖。
body = ensureDeepSeekChatReasoningPlaceholders(account, body)
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body))
releaseUpstreamCtx()
@@ -0,0 +1,193 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func openaiPlatformDeepSeekAccount() *Account {
return &Account{
ID: 18,
Name: "openai-deepseek-apikey",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
},
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": DefaultDeepseekBaseURL,
},
Status: StatusActive,
Schedulable: true,
}
}
func deepSeekChatFallbackTestConfig() *config.Config {
return &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{
Enabled: false,
AllowInsecureHTTP: true,
},
},
}
}
func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) {
deepSeekAccount := openaiPlatformDeepSeekAccount()
nativeDeepSeek := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
otherOpenAI := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"},
}
missing := []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
withPlain := []byte(`{"model":"deepseek-chat","messages":[{"role":"assistant","reasoning_content":"real thinking","content":"ok"}]}`)
t.Run("openai_platform_deepseek_fills_empty_assistant", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, missing)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists())
require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists())
})
t.Run("native_deepseek_platform_fills_empty_assistant", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(nativeDeepSeek, missing)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
})
t.Run("does_not_overwrite_plaintext", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, withPlain)
require.Equal(t, "real thinking", gjson.GetBytes(got, "messages.0.reasoning_content").String())
require.Equal(t, string(withPlain), string(got), "已有明文时必须原样返回")
})
t.Run("non_deepseek_upstream_unchanged", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(otherOpenAI, missing)
require.Equal(t, string(missing), string(got))
require.False(t, gjson.GetBytes(got, "messages.1.reasoning_content").Exists())
})
t.Run("nil_account_unchanged", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(nil, missing)
require.Equal(t, string(missing), string(got))
})
}
type reasoningHitCache struct {
stubGatewayCache
values map[string]string
}
func (c *reasoningHitCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
if v, ok := c.values[itemID]; ok {
return v, nil
}
return "", ErrReasoningContentNotFound
}
func deepSeekChatHistoryWithEncryptedReasoning() []byte {
return []byte(`{
"model":"gpt-5.6-sol",
"stream":false,
"input":[
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]
}`)
}
func newDeepSeekChatFallbackContext(t *testing.T, body []byte) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
return c
}
func newOKChatCompletionsUpstream(requestID, body string) *httpUpstreamRecorder {
return &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{requestID}},
Body: io.NopCloser(strings.NewReader(body)),
}}
}
const deepSeekChatFallbackOKBody = `{"id":"chatcmpl_ph","object":"chat.completion","model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
func TestForwardResponses_DeepSeekChatFallbackInjectsReasoningPlaceholderOnCacheMiss(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_ds_rc_placeholder", deepSeekChatFallbackOKBody)
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
}
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Contains(t, upstream.lastReq.URL.String(), "/chat/completions")
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
}
func TestForwardResponses_DeepSeekChatFallbackKeepsCachedReasoningContent(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_ds_rc_cached", deepSeekChatFallbackOKBody)
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
cache: &reasoningHitCache{values: map[string]string{"item_enc1": "cached thinking"}},
}
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
}
func TestForwardResponses_NonDeepSeekChatFallbackDoesNotInjectReasoningPlaceholder(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_other_rc", deepSeekChatFallbackOKBody)
account := &Account{
ID: 99,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
},
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "http://upstream.example",
},
}
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").Exists())
}
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -14,6 +16,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
@@ -399,3 +403,67 @@ func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) {
)
}
}
// deepSeekChatReasoningPlaceholderText 是 Chat Completions 侧 thinking-mode
// 占位明文。必须是单个空格:DeepSeek 拒绝空串,非空即可通过;LiteLLM 同样注入
// 单个空格。
const deepSeekChatReasoningPlaceholderText = " "
// targetsDeepSeekAPIHost 报告该账号实际上游是否是 DeepSeek 官方 API。
// platform=deepseek 直接命中;platform=openai 但 base_url 指向
// api.deepseek.com 的映射账号同样命中(Codex 把 GPT 模型名映射到 DeepSeek
// 时的典型接入方式)。
func targetsDeepSeekAPIHost(account *Account) bool {
if account == nil {
return false
}
if account.Platform == PlatformDeepseek {
return true
}
u, err := url.Parse(strings.TrimSpace(account.GetOpenAIBaseURL()))
if err != nil {
return false
}
ds, err := url.Parse(DefaultDeepseekBaseURL)
if err != nil {
return false
}
return strings.EqualFold(u.Hostname(), ds.Hostname())
}
// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant
// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的
// assistant 消息都回传该字段,否则 400
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
//
// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。
// 非 DeepSeek 上游原样返回(字节不变)。
func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte {
if !targetsDeepSeekAPIHost(account) {
return body
}
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return body
}
updated := body
changed := false
for i, msg := range messages.Array() {
if strings.TrimSpace(msg.Get("role").String()) != "assistant" {
continue
}
if msg.Get("reasoning_content").String() != "" {
continue
}
next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning_content", deepSeekChatReasoningPlaceholderText)
if err != nil {
return body
}
updated = next
changed = true
}
if !changed {
return body
}
return updated
}
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
)
// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
@@ -35,22 +37,65 @@ func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
}
// cnQuotaExhausted403ErrorType 是 Kimi Coding Plan 配额窗口耗尽的 403 错误类型。
// 与并发限制 403(见 isCNProviderConcurrencyLimit403)不同,这是 5h/weekly
// 窗口耗尽的限流信号:窗口到期后自动恢复,绝不能落入通用 403 升级计数
// (连续 3 次永久 SetError),必须按 429 口径冷却到真实窗口重置点。
const cnQuotaExhausted403ErrorType = "access_terminated_error"
// cnQuotaExhaustedReasonPrefix 是配额耗尽 403 临时停调 reason 的稳定前缀。
const cnQuotaExhaustedReasonPrefix = "cn_quota_exhausted"
// isCNProviderQuotaExhausted403 识别 CN 供应商配额窗口耗尽的 403。
// 先做廉价的文案子串匹配(usage limit / quota will reset),未命中再解析
// 响应体匹配结构化错误类型(Kimi 的 access_terminated_error),以兼容同族
// 供应商的文案变体。与并发限制文案互斥,两类信号不会误判。
// 仅限 Coding Plan 账号:滚动窗口快照(含重置时间)只有 Coding Plan 账号才有,
// 非 Coding Plan 账号的同类 403 语义不明(可能是需要升级套餐的硬上限),
// 应回退到通用 403 升级逻辑而非无限临时冷却。
func isCNProviderQuotaExhausted403(account *Account, responseBody []byte, upstreamMsg string) bool {
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() {
return false
}
msg := strings.ToLower(strings.TrimSpace(upstreamMsg))
if strings.Contains(msg, "usage limit") || strings.Contains(msg, "quota will reset") {
return true
}
return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String()), cnQuotaExhausted403ErrorType)
}
// handleCNProviderQuotaExhausted403 把 CN 供应商配额窗口耗尽的 403 按 429
// 同口径处理:有配额快照时冷却到最早的未来窗口重置点(SetRateLimited,
// 窗口过期后调度自动恢复);快照缺失(额度探测尚未刷新)时兜底默认 403
// 冷却时长的临时停调,避免过度停调到数天后的窗口。无论哪条路径都不写
// 账号 error 状态。
func (s *RateLimitService) handleCNProviderQuotaExhausted403(
ctx context.Context,
account *Account,
upstreamMsg string,
) {
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, cnQuotaExhaustedReasonPrefix, "cn_quota_exhausted_rate_limited") != nil {
return
}
// upstreamMsg 由调用方(HandleUpstreamError)从同一响应体提取,此处直接复用;
// 为空时仅存前缀,保证 reason 稳定可检索。
reason := cnQuotaExhaustedReasonPrefix
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
reason += ": " + msg
}
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
s.setCNProviderTempUnschedulable(ctx, account, until, cnQuotaExhaustedReasonPrefix, reason, "cn_quota_exhausted_temp_unschedulable")
}
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
ctx context.Context,
account *Account,
) {
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info("cn_provider_concurrency_limited",
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
s.setCNProviderTempUnschedulable(ctx, account, until,
cnConcurrencyLimitReasonPrefix, cnConcurrencyLimitReasonPrefix+": "+kimiConcurrentRequestLimitMessage,
"cn_provider_concurrency_limited")
}
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
@@ -151,6 +196,45 @@ func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
return earliest
}
// cooldownCNProviderToQuotaSnapshotReset 把账号冷却到配额快照中最早的未来窗口
// 重置点(SetRateLimited,窗口过期后调度自动恢复),供 429(OpenCodeGo /
// Coding Plan)与配额耗尽 403 共用。仅在冷却成功持久化后返回非 nil;
// 无快照或写库失败时返回 nil,由调用方落入各自的兜底逻辑(避免写库失败
// 时账号完全失去持久冷却)。
func (s *RateLimitService) cooldownCNProviderToQuotaSnapshotReset(ctx context.Context, account *Account, reason, logEvent string) *time.Time {
until := cnProviderQuotaSnapshotReset(account, time.Now())
if until == nil {
return nil
}
s.notifyAccountSchedulingBlocked(account, *until, reason)
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return nil
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"reset_at", until.UTC(),
)
return until
}
// setCNProviderTempUnschedulable 写临时停调(含调度层通知与日志),供 CN 403
// 各分支(并发限制、配额耗尽兜底)共用。告警 key 由 notifyReason 派生,保持
// 各分支既有日志 key 稳定(<prefix>_set_temp_unschedulable_failed)。
func (s *RateLimitService) setCNProviderTempUnschedulable(ctx context.Context, account *Account, until time.Time, notifyReason, storeReason, logEvent string) {
s.notifyAccountSchedulingBlocked(account, until, notifyReason)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, storeReason); err != nil {
slog.Warn(notifyReason+"_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
}
// applyCNProviderReactive429 处理国产供应商的 429 响应。
// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
func (s *RateLimitService) applyCNProviderReactive429(
@@ -160,17 +244,7 @@ func (s *RateLimitService) applyCNProviderReactive429(
responseBody []byte,
) bool {
if account.IsOpenCodeGo() {
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
s.notifyAccountSchedulingBlocked(account, *until, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("opencode_go_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", *until,
)
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "opencode_go_rate_limited") != nil {
return true
}
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
@@ -199,20 +273,9 @@ func (s *RateLimitService) applyCNProviderReactive429(
}
// 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
// cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
if account.IsCodingPlan() {
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
s.notifyAccountSchedulingBlocked(account, *until, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("cn_coding_plan_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", *until,
)
return true
}
if account.IsCodingPlan() &&
s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "cn_coding_plan_rate_limited") != nil {
return true
}
return false
}
@@ -992,6 +992,14 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
s.handleCNProviderConcurrencyLimit403(ctx, account)
return true
}
// Kimi 等 CN 供应商把 Coding Plan 配额窗口耗尽打成 403
// (error.type=access_terminated_error),这是窗口到期后自动恢复的限流
// 信号而非封禁:按 429 口径冷却到真实窗口重置点,避免落入下方通用 403
// 升级计数后被永久 SetError。
if isCNProviderQuotaExhausted403(account, responseBody, upstreamMsg) {
s.handleCNProviderQuotaExhausted403(ctx, account, upstreamMsg)
return true
}
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
@@ -17,6 +17,7 @@ type rateLimitAccountRepoStub struct {
mockAccountRepoForGemini
setErrorCalls int
tempCalls int
rateLimitedCalls int
updateCredentialsCalls int
updateExtraCalls int
lastCredentials map[string]any
@@ -25,6 +26,8 @@ type rateLimitAccountRepoStub struct {
lastTempReason string
lastErrorID int64
lastTempID int64
lastRateLimitedID int64
lastRateLimitedAt time.Time
tempErr error
}
@@ -42,6 +45,13 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
return r.tempErr
}
func (r *rateLimitAccountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
r.rateLimitedCalls++
r.lastRateLimitedID = id
r.lastRateLimitedAt = resetAt
return nil
}
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
r.updateCredentialsCalls++
r.lastCredentials = shallowCopyMap(credentials)
@@ -0,0 +1,171 @@
//go:build unit
package service
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// newCNQuotaExhaustedTestAccount 构造一个 Kimi Coding Plan 账号:
// account_mode=coding 使 IsCodingPlan() 为真,Extra 带 5h/weekly 窗口快照。
func newCNQuotaExhaustedTestAccount(resetIn time.Duration) *Account {
extra := map[string]any{}
if resetIn > 0 {
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)] = 100
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hReset)] = time.Now().Add(resetIn).UTC().Format(time.RFC3339)
extra[cnExtraKey(PlatformKimi, cnExtraSuffixWeeklyReset)] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
}
return &Account{
ID: 401,
Platform: PlatformKimi,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"account_mode": AccountModeCoding},
Extra: extra,
}
}
const kimiWeeklyQuotaExhaustedBody = `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/membership/subscription?tab=quota","type":"access_terminated_error"}}`
// runKimi403 以 403 + 给定响应体驱动 HandleUpstreamError,返回 shouldDisable。
func runKimi403(service *RateLimitService, account *Account, body string) bool {
return service.HandleUpstreamError(
context.Background(),
account,
http.StatusForbidden,
http.Header{},
[]byte(body),
)
}
func TestHandleUpstreamError_KimiQuotaExhausted403RateLimitedToWindowReset(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
blocker := &runtimeBlockRecorder{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetAccountRuntimeBlocker(blocker)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls, "quota exhaustion must never SetError")
require.Equal(t, 0, repo.tempCalls)
require.Equal(t, 1, repo.rateLimitedCalls)
require.Equal(t, account.ID, repo.lastRateLimitedID)
require.True(t, repo.lastRateLimitedAt.After(time.Now()), "cooldown should target the future window reset")
require.True(t, repo.lastRateLimitedAt.Before(time.Now().Add(3*time.Hour)), "should cool down to the nearer 5h reset, not weekly")
require.Len(t, blocker.accounts, 1)
require.Equal(t, cnQuotaExhaustedReasonPrefix, blocker.reasons[0])
}
func TestHandleUpstreamError_KimiQuotaExhausted403WithoutSnapshotFallsBackToTemp(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(0)
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 0, repo.rateLimitedCalls)
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, cnQuotaExhaustedReasonPrefix)
require.Greater(t, len(repo.lastTempReason), len(cnQuotaExhaustedReasonPrefix))
}
func TestHandleUpstreamError_KimiQuotaExhausted403MatchedByMessageOnly(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
// 无 error.type 字段,仅靠文案兜底匹配。
body := `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}`
shouldDisable := runKimi403(service, account, body)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 1, repo.rateLimitedCalls)
}
func TestHandleUpstreamError_KimiConcurrencyLimit403StillUsesConcurrencyPath(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
body := fmt.Sprintf(`{"error":{"message":%q,"type":"access_terminated_error"}}`, kimiConcurrentRequestLimitMessage)
shouldDisable := runKimi403(service, account, body)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 0, repo.rateLimitedCalls, "concurrency-limit message must keep its dedicated path")
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix)
}
func TestHandleUpstreamError_KimiGeneric403StillEscalates(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{3}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
shouldDisable := runKimi403(service, account, `{"error":{"message":"policy rejected this request"}}`)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-quota 403 keeps the generic escalation path")
require.Equal(t, 0, repo.rateLimitedCalls)
}
func TestHandleUpstreamError_KimiNonCodingPlanQuotaMessageUsesGeneric403(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{3}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
account.Credentials = map[string]any{"account_mode": AccountModePayG}
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-coding-plan account must keep the generic 403 escalation")
require.Equal(t, 0, repo.rateLimitedCalls)
require.Equal(t, 0, repo.tempCalls)
}
func TestHandleUpstreamError_NonCNAccessTerminated403KeepsGenericPath(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{1}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := &Account{
ID: 402,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
}
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.rateLimitedCalls, "non-CN platform must not enter the CN quota path")
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, "(1/3)")
}
func TestIsCNProviderQuotaExhausted403_Classification(t *testing.T) {
kimi := newCNQuotaExhaustedTestAccount(0)
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(kimiWeeklyQuotaExhaustedBody), ""))
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"You've reached your weekly (7-day) usage limit."}}`), "You've reached your weekly (7-day) usage limit."))
require.False(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"policy rejected"}}`), "policy rejected"))
require.False(t, isCNProviderQuotaExhausted403(kimi, nil, ""))
openai := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
require.False(t, isCNProviderQuotaExhausted403(openai, []byte(kimiWeeklyQuotaExhaustedBody), ""))
require.False(t, isCNProviderQuotaExhausted403(nil, []byte(kimiWeeklyQuotaExhaustedBody), ""))
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create"
SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status"
SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete"
OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance"
)
func (e GrokMediaEndpoint) IsSeedance() bool {
return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
}
// SeedanceTaskKey isolates ownership and billing keys from other video providers.
func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) }
func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) {
var info GrokMediaRequestInfo
if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() {
return info, fmt.Errorf("request body must be a JSON object")
}
model := gjson.GetBytes(body, "model")
if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" {
return info, fmt.Errorf("model is required")
}
content := gjson.GetBytes(body, "content")
if !content.IsArray() || len(content.Array()) == 0 {
return info, fmt.Errorf("content must be a non-empty array")
}
info.Model = strings.TrimSpace(model.String())
var texts []string
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
texts = append(texts, item.Get("text").String())
case "image_url":
info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String())
}
}
info.Prompt = strings.Join(texts, "\n")
return info, nil
}
func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) {
base = strings.TrimRight(base, "/")
// Accept an origin, a proxy prefix, or the full Ark API base.
if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") {
base += "/api/v3"
}
base += "/contents/generations/tasks"
if endpoint != SeedanceEndpointCreate {
if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" {
return "", fmt.Errorf("invalid Seedance task ID")
}
base += "/" + taskID
}
return base, nil
}
// ForwardSeedance preserves the Ark protocol, including multimodal content and
// future fields. Only model is rewritten using the account's configured mapping.
func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) {
if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() {
return nil, fmt.Errorf("seedance requires an OpenAI API key account with a custom base URL")
}
base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url"))
if err != nil {
return nil, err
}
target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:"))
if err != nil {
return nil, err
}
model, upstreamModel := "", ""
method := http.MethodGet
switch endpoint {
case SeedanceEndpointCreate:
info, parseErr := ParseSeedanceRequest(body)
if parseErr != nil {
return nil, parseErr
}
model = info.Model
upstreamModel = account.GetMappedModel(model)
body, err = sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return nil, err
}
method = http.MethodPost
case SeedanceEndpointDelete:
method = http.MethodDelete
}
token := strings.TrimSpace(account.GetCredential("api_key"))
if token == "" {
return nil, fmt.Errorf("seedance account missing api_key")
}
started := time.Now()
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
account.ApplyHeaderOverrides(req.Header)
proxy := ""
if account.ProxyID != nil && account.Proxy != nil {
proxy = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds())
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return nil, err
}
// Do not retry ambiguous asynchronous creates: the upstream may already have
// accepted a billable job. Preserve native error codes and response bodies.
if resp.StatusCode >= 300 {
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
return nil, fmt.Errorf("seedance upstream status %d", resp.StatusCode)
}
result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()}
if endpoint == SeedanceEndpointCreate {
id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String())
if id == "" {
return nil, fmt.Errorf("seedance create response missing task ID")
}
result.ResponseID = SeedanceTaskKey(id)
}
if endpoint == SeedanceEndpointStatus {
result.ResponseID = taskID
result.UpstreamModel = gjson.GetBytes(responseBody, "model").String()
if gjson.GetBytes(responseBody, "status").String() == "succeeded" {
result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int()))
}
}
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
return result, nil
}
+112
View File
@@ -0,0 +1,112 @@
//go:build unit
package service
import (
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func seedanceTestAccount() *Account {
return &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{
"api_key": "ark-secret", "base_url": "https://ark.cn-beijing.volces.com/api/v3",
"openai_capabilities": []string{"seedance"},
"model_mapping": map[string]any{"video": "ep-seedance"},
}}
}
func TestSeedanceNativeForwarding(t *testing.T) {
body := []byte(`{"model":"video","content":[{"type":"text","text":"waves"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"audio_url","audio_url":{"url":"https://example.com/audio.mp3"}}],"duration":-1,"generate_audio":true,"future_field":{"keep":true}}`)
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"id":"task-1"}`)}
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", body)
require.NoError(t, err)
require.JSONEq(t, `{"id":"task-1"}`, w.Body.String())
require.Equal(t, "seedance:task-1", result.ResponseID)
require.Zero(t, result.Usage.OutputTokens)
require.Equal(t, "video", result.BillingModel)
require.Equal(t, "ep-seedance", result.UpstreamModel)
require.Equal(t, "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", upstream.request.URL.String())
require.Equal(t, "Bearer ark-secret", upstream.request.Header.Get("Authorization"))
forwarded, err := io.ReadAll(upstream.request.Body)
require.NoError(t, err)
require.Equal(t, "ep-seedance", gjson.GetBytes(forwarded, "model").String())
for _, field := range []string{"content", "duration", "generate_audio", "future_field"} {
require.Equal(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(forwarded, field).Raw)
}
}
func TestSeedanceStatusAndDelete(t *testing.T) {
for _, status := range []string{"queued", "running", "failed", "cancelled", "expired", "succeeded"} {
t.Run(status, func(t *testing.T) {
body := `{"id":"task-1","status":"` + status + `","model":"ep-seedance","content":{"video_url":"https://cdn.example/video.mp4"},"usage":{"completion_tokens":12345}}`
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(body)}
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodGet, "/api/v3/contents/generations/tasks/task-1", nil)
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointStatus, "seedance:task-1", nil)
require.NoError(t, err)
require.JSONEq(t, body, w.Body.String())
require.Equal(t, "/api/v3/contents/generations/tasks/task-1", upstream.request.URL.Path)
if status == "succeeded" {
require.Equal(t, 12345, result.Usage.OutputTokens)
} else {
require.Zero(t, result.Usage.OutputTokens)
}
require.Zero(t, result.VideoCount, "must use token billing, not Grok seconds")
})
}
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse("")}
upstream.response.StatusCode = http.StatusNoContent
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodDelete, "/api/v3/contents/generations/tasks/task-1", nil)
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointDelete, "seedance:task-1", nil)
require.NoError(t, err)
require.Equal(t, http.MethodDelete, upstream.request.Method)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestSeedanceValidationAndCapability(t *testing.T) {
for _, body := range []string{`{`, `[]`, `{}`, `{"model":12,"content":[{}]}`, `{"model":"x","content":[]}`} {
_, err := ParseSeedanceRequest([]byte(body))
require.Error(t, err)
}
info, err := ParseSeedanceRequest([]byte(`{"model":"x","content":[{"type":"text","text":"first"},{"type":"text","text":"second"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`))
require.NoError(t, err)
require.Contains(t, string(info.ModerationBody()), "first")
require.Contains(t, string(info.ModerationBody()), "second")
require.Contains(t, string(info.ModerationBody()), "https://example.com/a.png")
for _, id := range []string{"", "..", "a/b", "a?b", "a#b", "%2e%2e"} {
_, err := buildSeedanceURL("https://example.com", SeedanceEndpointStatus, id)
require.Error(t, err, id)
}
for _, base := range []string{"https://example.com", "https://example.com/api/v3/", "https://example.com/v3"} {
url, err := buildSeedanceURL(base, SeedanceEndpointCreate, "")
require.NoError(t, err)
require.NotContains(t, url, "/v3/api/v3")
}
a := seedanceTestAccount()
require.True(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
a.Type = AccountTypeOAuth
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
a.Type = AccountTypeAPIKey
delete(a.Credentials, "openai_capabilities")
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
}
func TestSeedancePreservesUpstreamErrorsWithoutRetry(t *testing.T) {
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"error":{"code":"QuotaExceeded","message":"quota exhausted"}}`)}
upstream.response.StatusCode = 429
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", []byte(`{"model":"video","content":[{"type":"text","text":"waves"}]}`))
require.Error(t, err)
require.Equal(t, 429, w.Code)
require.Contains(t, w.Body.String(), "QuotaExceeded")
require.Len(t, upstream.requests, 1)
}
+45
View File
@@ -0,0 +1,45 @@
# Seedance 原生 API
支持火山方舟 Ark 的异步视频任务协议,无需把 `content[]` 转换成 OpenAI `messages` 或 Grok `prompt`。
## 配置
1. 创建 OpenAI 平台的 **API Key** 账号,填写 Ark API Key,Base URL 使用 `https://ark.cn-beijing.volces.com/api/v3`。兼容服务可填写自己的 `/api/v3` 或 `/v3` Base URL。
2. 在账号的端点能力中勾选 **Seedance (Ark)**。默认不启用,避免请求误调度到其他 OpenAI 账号。支持创建、编辑与批量编辑。
3. 将账号加入 OpenAI 分组并启用分组的「允许图片生成」媒体权限;合成分组也可路由到这些账号。
4. 配置模型映射,例如将公开模型名 `seedance-video` 映射到实际 `doubao-seedance-*` 模型或 `ep-*` 推理接入点。配置对应模型的输出 token 价格;本接口不使用 Grok 的按秒视频价格。
## 调用
```bash
curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks" \
-H "Authorization: Bearer $SUB2API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"model": "seedance-video",
"content": [{"type": "text", "text": "海浪轻轻拍打沙滩"}],
"duration": 5,
"resolution": "720p",
"ratio": "16:9",
"generate_audio": true
}'
# 使用创建响应中的原生 id 查询,直至 succeeded / failed / cancelled 等终态。
curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \
-H "Authorization: Bearer $SUB2API_KEY"
curl -X DELETE "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \
-H "Authorization: Bearer $SUB2API_KEY"
```
亦支持 `/v3`、`/v1` 和无版本前缀别名。Ark SDK 的 Base URL 可改为 `$SUB2API_BASE_URL/api/v3`。文本、图片、视频、音频内容、角色及扩展参数原样传递,仅按账号配置改写模型名;响应保持上游原生格式。
## 任务与计费
- 查询和删除只能访问同一用户、同一 API Key、同一分组创建的任务,并始终使用原提交账号;不会转到其他账号查询。
- 创建时不扣 token 用量。首次查询到 `succeeded` 后,根据上游 `usage.completion_tokens` 计费;重复查询由共享缓存声明和持久化用量去重共同保护。失败、排队及运行中的任务不计费。
- Redis 保存任务绑定及创建时的模型快照,默认 24 小时。需保留 Redis 状态并在有效期内查询完成结果。当前不会后台轮询;只使用回调而不查询的任务不会自动结算。
- 不开放上游的任务列表接口,防止共享账号的任务泄露给其他用户。删除遵循上游语义,不自动退款。
- 异步创建的上游错误不自动重试,以免重复创建付费任务。
协议依据:[火山官方 Go SDK](https://github.com/volcengine/volcengine-go-sdk/blob/master/service/arkruntime/model/content_generation.go)、[创建任务文档](https://www.volcengine.com/docs/82379/1520757)、[查询任务文档](https://www.volcengine.com/docs/82379/1521309)。
@@ -1786,7 +1786,8 @@ const openAIEndpointCapabilityOptions = computed<
Array<{ value: OpenAIEndpointCapability; label: string }>
>(() => [
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') },
{ value: 'seedance', label: 'Seedance (Ark)' }
])
const openAITextGenerationCapabilityEnabled = computed(() =>
openAIEndpointCapabilities.value.includes('chat_completions')
@@ -1796,9 +1797,9 @@ const openAIResponsesModeApplicable = computed(
)
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance']
const selected = allowed.filter((value) => values.includes(value))
return selected.length > 0 ? selected : allowed
return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[]
}
const toggleOpenAIEndpointCapability = (
@@ -1996,7 +1997,7 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
if (applyOpenAIEndpointCapabilities) {
credentials.openai_capabilities =
openAIEndpointCapabilities.value.length === 2
openAIEndpointCapabilities.value.length === 2 && !openAIEndpointCapabilities.value.includes('seedance')
? null
: [...openAIEndpointCapabilities.value]
credentialsChanged = true
@@ -4524,16 +4524,17 @@ const openAITextEndpointCapabilityLabel = computed(() => {
})
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') },
{ value: 'seedance', label: 'Seedance (Ark)' }
])
const openAITextGenerationCapabilityEnabled = computed(() =>
openAIEndpointCapabilities.value.includes('chat_completions')
)
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance']
const selected = allowed.filter((value) => values.includes(value))
return selected.length > 0 ? selected : allowed
return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[]
}
const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => {
@@ -4559,7 +4560,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev
const applyOpenAIEndpointCapabilities = (credentials: Record<string, unknown>) => {
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
if (capabilities.length === 2) {
if (capabilities.length === 2 && !capabilities.includes('seedance')) {
delete credentials.openai_capabilities
return
}
@@ -3683,16 +3683,17 @@ const openAITextEndpointCapabilityLabel = computed(() => {
})
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
{ value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value },
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') },
{ value: 'seedance', label: 'Seedance (Ark)' }
])
const openAITextGenerationCapabilityEnabled = computed(() =>
openAIEndpointCapabilities.value.includes('chat_completions')
)
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance']
const selected = allowed.filter((value) => values.includes(value))
return selected.length > 0 ? selected : allowed
return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[]
}
const readOpenAIEndpointCapabilities = (credentials?: Record<string, unknown>): OpenAIEndpointCapability[] => {
@@ -3700,7 +3701,7 @@ const readOpenAIEndpointCapabilities = (credentials?: Record<string, unknown>):
if (Array.isArray(raw)) {
return normalizeOpenAIEndpointCapabilities(
raw.filter((value): value is OpenAIEndpointCapability =>
value === 'chat_completions' || value === 'embeddings'
value === 'chat_completions' || value === 'embeddings' || value === 'seedance'
)
)
}
@@ -3738,7 +3739,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev
const applyOpenAIEndpointCapabilities = (credentials: Record<string, unknown>) => {
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
if (capabilities.length === 2) {
if (capabilities.length === 2 && !capabilities.includes('seedance')) {
delete credentials.openai_capabilities
return
}
@@ -557,6 +557,18 @@ describe('BulkEditAccountModal', () => {
})
})
it('persists Seedance in a two-capability bulk update', async () => {
const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] })
await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true)
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false)
await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-seedance"]').setValue(true)
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
await flushPromises()
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
credentials: { openai_capabilities: ['chat_completions', 'seedance'] }
})
})
it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
@@ -1311,6 +1311,19 @@ describe('EditAccountModal', () => {
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_disabled).toBeUndefined()
})
it('preserves Seedance when exactly two endpoint capabilities are selected', async () => {
const account = buildAccount()
account.credentials.openai_capabilities = ['chat_completions', 'seedance']
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.get<HTMLInputElement>('[data-testid="openai-endpoint-capability-seedance"]').element.checked).toBe(true)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual(['chat_completions', 'seedance'])
})
it('keeps at least one OpenAI APIKey endpoint capability selected', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
+4 -2
View File
@@ -38,11 +38,13 @@
<span class="hidden sm:inline">{{ t('nav.docs') }}</span>
</a>
<!-- Model Plaza Entry -->
<!-- Model Plaza Entry (icon only below sm) -->
<router-link
v-if="user && modelPlazaEnabled"
:to="{ path: '/model-plaza', query: { embedded: '1' } }"
class="hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white sm:flex"
:title="t('nav.modelPlaza')"
:aria-label="t('nav.modelPlaza')"
class="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white"
>
<Icon name="grid" size="sm" />
<span class="hidden sm:inline">{{ t('nav.modelPlaza') }}</span>
+1 -1
View File
@@ -1455,7 +1455,7 @@ export interface CodexUsageSnapshot {
export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off'
export type OpenAIResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions'
export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings'
export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings' | 'seedance'
export interface OpenAICompactState {
openai_compact_mode?: OpenAICompactMode