mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea725f2ea | ||
|
|
74431ffad8 | ||
|
|
0c9e83b59c | ||
|
|
09f88865e0 | ||
|
|
1b45d21328 | ||
|
|
d5cea617a5 | ||
|
|
4275047d16 | ||
|
|
4dbcdce43c | ||
|
|
a8a1a3a958 | ||
|
|
db8692d679 | ||
|
|
bcc73f8d40 | ||
|
|
a9c7c6e8bf | ||
|
|
0f4d8acaa8 | ||
|
|
f79b8bf96c | ||
|
|
d099c06945 | ||
|
|
aba34524f1 |
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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), ""))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user